Repository: CSCfi/rems
Pull request #2706 (2021-08-22 18:04:15)

Invite handler
Implements #2650.

![accepted](https://user-images.githubusercontent.com/823661/130365250-cc474abd-b010-422e-a5d9-18a39a270099.png)

Open questions
- Does the workflow need to be active or is any ok? Perhaps it's ok, this is an admin feature anyway.
- Should a mail be sent (to the inviter) when the invitation is accepted?
- Invitation/sent is the time when it's put to outbox, which is earlier than real email sent time but should not be by much. We have no mechanism to easily set the time but one could be developed.

# Checklist for author

Remove items that aren't applicable, check items that are done.

## Reviewability
- [x] Link to issue
- [x] Consider adding screenshots for ease of review

## Backwards compatibility
- [x] API is backwards compatible or **completely new**

## Documentation
- [x] Update changelog if necessary
- [x] API is documented and shows up in Swagger UI
- [x] Update manual/ (if applicable)
- [x] ADR for major architectural decisions or experiments

## Testing
- [x] Complex logic is unit tested
- [x] Valuable features are integration / browser / acceptance tested automatically

## Follow-up
- [x] New tasks are created for pending or remaining tasks



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef6efb00c..dc077b621 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,8 +8,12 @@ have notable changes.
 
 Changes since v2.20
 
+**NB: This release contains migrations!**
+
 ### Additions
 - "More info" support for DOI style resources (when `:enable-doi` flag is set) (#2701)
+- There is a new v2 push API to sync entitlements outside of REMS. So far the only supported type is `:ega`. See [configuration.md](docs/configuration.md) for more details. (#2466)
+- Handlers can be invited to a workflow by email using the API (#2650)
 
 ## v2.20 "Vattuniemenkatu" 2021-08-24
 
@@ -24,7 +28,6 @@ Changes since v2.20
 
 ### Additions
 - Pdf applications now contain license text (for inline licenses), url (for link licenses) or attachment name (for attachment licenses), and license acceptance status for applicant and members. (#2688)
-- There is a new v2 push API to sync entitlements outside of REMS. So far the only supported type is `:ega`. See [configuration.md](docs/configuration.md) for more details. (#2466)
 
 ## v2.18 "Särkiniemientie" 2021-05-18
 
diff --git a/manual/owner.md b/manual/owner.md
index c0e890570..1717fce00 100644
--- a/manual/owner.md
+++ b/manual/owner.md
@@ -120,6 +120,8 @@ Handlers do not get notified when they have been assigned as a handler but they
 
 :exclamation: Note! After saving a workflow, you cannot edit anything else than its name, organization and the handlers.
 
+:exclamation: It's possible to invite a handler by email, if they do not have a REMS user yet. At the moment, this is only possible using the API.
+
 #### Adding a form to a workflow
 
 You can connect an application form to a workflow. This is useful, for example, if you have some **basic questions that apply to all the applications**, for example, billing information. When you use a workflow that has a form connected to it, you do not have to add the same questions manually every time you create a new application form.
diff --git a/resources/migrations/20210810150445-invitations.down.sql b/resources/migrations/20210810150445-invitations.down.sql
new file mode 100644
index 000000000..d7044f6f1
--- /dev/null
+++ b/resources/migrations/20210810150445-invitations.down.sql
@@ -0,0 +1,1 @@
+DROP TABLE invitation;
diff --git a/resources/migrations/20210810150445-invitations.up.sql b/resources/migrations/20210810150445-invitations.up.sql
new file mode 100644
index 000000000..22a683f3e
--- /dev/null
+++ b/resources/migrations/20210810150445-invitations.up.sql
@@ -0,0 +1,4 @@
+CREATE TABLE invitation (
+  id serial NOT NULL PRIMARY KEY,
+  invitationdata jsonb NOT NULL
+)
diff --git a/resources/sql/queries.sql b/resources/sql/queries.sql
index e82bcf959..d6a4a65dc 100644
--- a/resources/sql/queries.sql
+++ b/resources/sql/queries.sql
@@ -652,3 +652,30 @@ RETURNING id;
 UPDATE organization
 SET data = :data::jsonb, modified = :time, modifierUserId = :user
 WHERE id = :id;
+
+-- :name add-invitation! :insert
+INSERT INTO invitation (invitationdata)
+VALUES (:invitationdata::jsonb)
+ON CONFLICT (id) DO NOTHING
+RETURNING id;
+
+-- :name get-invitations :? :*
+SELECT id, invitationdata::TEXT
+FROM invitation
+WHERE 1 = 1
+/*~ (when (:token params) */
+  AND invitationdata->>'invitation/token' = :token
+/*~ ) ~*/
+/*~ (when (:ids params) */
+  AND id IN (:v*:ids)
+/*~ ) ~*/
+ORDER BY id ASC;
+
+-- :name set-invitation! :!
+UPDATE invitation
+SET invitationdata = :invitationdata::jsonb
+WHERE id = :id;
+
+-- :name delete-invitation! :!
+DELETE FROM invitation
+WHERE id = :id;
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index e088c93fe..58bd958de 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -1,5 +1,12 @@
 {:t
- {:actions {:accept-invitation "Accept invitation"
+ {:accept-invitation {:header "Accept invitation"
+                      :title "Accept invitation"
+                      :success {:workflow "Successfully joined workflow handling."}
+                      :errors {:already-member {:workflow "You are already a handler."}
+                               :invalid-invitation-type "Accepting invitation failed."
+                               :invalid-token [:span "Accepting the invitation failed. Check the invitation token " [:code "%1"]]
+                               :invalid-workflow "Accepting invitation failed."}}
+  :actions {:accept-invitation "Accept invitation"
             :accept-invitation-already-member "You are already a member."
             :accept-invitation-success "Successfully joined the application."
             :accept-licenses "Accept the terms of use"
@@ -403,7 +410,9 @@
                              :message "Dear %1,\n\nYou have been invited to participate in handling application %3, by %2.\n\nYou can view the application at %4"}
           :reviewer-reminder {:application "%1, submitted by %2"
                               :message "Dear %1,\n\nThe following applications are waiting for a review from you:\n\n%2\n\nYou can view the applications at %3"
-                              :subject "Applications pending review"}}
+                              :subject "Applications pending review"}
+          :workflow-handler-invitation {:message "Dear %1,\n\n%2 has invited you to be a handler of applications of workflow %3.\n\nYou can view the workflow at %4"
+                                        :subject "Invitation to handle applications"}}
   :footer "CSC – IT Center for Science"
   :form {:accepted-licenses "Accepted terms of use"
          :actions "Actions"
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index dbde8b7ae..559b86036 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -1,5 +1,12 @@
 {:t
- {:actions {:accept-invitation "Hyväksy kutsu"
+ {:accept-invitation {:header "Hyväksy kutsu"
+                      :title "Hyväksy kutsu"
+                      :success {:workflow "Liittyminen työvuon käsittelijäksi onnistui."}
+                      :errors {:already-member {:workflow "Olet jo työvuon käsittelijä."}
+                               :invalid-invitation-type "Hyväksyminen epäonnistui."
+                               :invalid-token [:div [:p "Hyväksyminen epäonnistui."] [:p "Tarkista kutsukoodi " [:code "%1"]]]
+                               :invalid-workflow "Hyväksyminen epäonnistui."}}
+  :actions {:accept-invitation "Hyväksy kutsu"
             :accept-invitation-already-member "Olet jo jäsen."
             :accept-invitation-success "Hakemukseen liittyminen onnistui."
             :accept-licenses "Hyväksy käyttöehdot"
@@ -362,7 +369,9 @@
                              :message "Hyvä %1,\n\nSinut on kutsuttu osallistumaan käyttäjän %2 jättämän hakemuksen %3 käsittelemiseen.\n\nVoit tarkastella hakemusta osoitteessa %4"}
           :reviewer-reminder {:application "%1, lähettänyt %2"
                               :message "Hyvä %1,\n\nSeuraavat hakemukset odottavat katselmointiasi:\n\n%2\n\nVoit tarkastella hakemuksia osoitteessa %3"
-                              :subject "Katselmointia odottavat hakemukset"}}
+                              :subject "Katselmointia odottavat hakemukset"}
+          :workflow-handler-invitation {:message "Hyvä %1,\n\n%2 on kutsunut sinut käsittelemään työvuon %3 hakemuksia.\n\nVoit tarkastella työvuota osoitteessa %4"
+                                        :subject "Kutsu käsittelemään hakemuksia"}}
   :footer "CSC – Tieteen tietotekniikan keskus"
   :form {:accepted-licenses "Käyttöehdot hyväksytty"
          :actions "Toiminnot"
diff --git a/resources/translations/sv.edn b/resources/translations/sv.edn
index 0726f3639..577f8bcb9 100644
--- a/resources/translations/sv.edn
+++ b/resources/translations/sv.edn
@@ -1,5 +1,12 @@
 {:t
- {:actions {:accept-invitation "Acceptera inbjudan"
+ {:accept-invitation {:header "Acceptera inbjudan"
+                      :title "Acceptera inbjudan"
+                      :success {:workflow "Du är nu handläggaren."}
+                      :errors {:already-member {:workflow "Du är redan handläggaren."}
+                               :invalid-invitation-type "Acceptering misslyckades."
+                               :invalid-token [:div [:p "Acceptering misslyckades."] [:p "Vänligen kontrollera koden för inbjudan " [:code "%1"]]]
+                               :invalid-workflow "Acceptering misslyckades."}}
+  :actions {:accept-invitation "Acceptera inbjudan"
             :accept-invitation-already-member "Du deltar redan."
             :accept-invitation-success "Du deltar nu i ansökan."
             :accept-licenses "Acceptera licenserna"
@@ -361,8 +368,10 @@
           :reviewer-invited {:subject "Inbjudan att delta i behandling av ansökan %3" ; TODO check
                              :message "%1,\n\nDu är inbjuden att delta i handling av %2s ansökan %3 om tillgång till material.\n\nDu kan granska ansökningen här: %4"} ; TODO check
           :reviewer-reminder {:application "%1, skickad av %2"
-                              :message "%1,\n\nFöljande ansökningar väntar på din granskning:\n\n%2\n\nDu kan granska ansökningarna här:  %3"
-                              :subject "Ansökningar väntar på granskning"}}
+                              :message "%1,\n\nFöljande ansökningar väntar på din granskning:\n\n%2\n\nDu kan granska ansökningarna här: %3"
+                              :subject "Ansökningar väntar på granskning"}
+          :workflow-handler-invitation {:message "%1,\n\n%2 har bjudit in dig som handläggare av ansökningar till arbetsflöde %3.\n\nDu kan granska arbetsflöden här: %4"
+                                        :subject "Inbjudan att delta i handling av ansökningar"}}
   :footer "CSC – IT-centret för forskning"
   :form {:accepted-licenses "Licenserna har accepterats"
          :actions "Funktioner"
diff --git a/src/clj/rems/api.clj b/src/clj/rems/api.clj
index defce81c1..06f1d6039 100644
--- a/src/clj/rems/api.clj
+++ b/src/clj/rems/api.clj
@@ -15,6 +15,7 @@
             [rems.api.extra-pages :refer [extra-pages-api]]
             [rems.api.forms :refer [forms-api]]
             [rems.api.health :refer [health-api]]
+            [rems.api.invitations :refer [invitations-api]]
             [rems.api.licenses :refer [licenses-api]]
             [rems.api.organizations :refer [organizations-api]]
             [rems.api.permissions :refer [permissions-api]]
@@ -184,6 +185,7 @@
       extra-pages-api
       forms-api
       health-api
+      invitations-api
       licenses-api
       organizations-api
       permissions-api
diff --git a/src/clj/rems/api/invitations.clj b/src/clj/rems/api/invitations.clj
new file mode 100644
index 000000000..25cbc285d
--- /dev/null
+++ b/src/clj/rems/api/invitations.clj
@@ -0,0 +1,64 @@
+(ns rems.api.invitations
+  (:require [compojure.api.sweet :refer :all]
+            [rems.api.services.invitation :as invitation]
+            [rems.api.util :refer [not-found-json-response]] ; required for route :roles
+            [rems.common.roles :refer [+admin-read-roles+ +admin-write-roles+]]
+            [rems.schema-base :as schema-base]
+            [rems.util :refer [getx-user-id]]
+            [ring.util.http-response :refer :all]
+            [schema.core :as s])
+  (:import [org.joda.time DateTime]))
+
+(s/defschema CreateInvitationCommand
+  {:name s/Str
+   :email s/Str
+   (s/optional-key :workflow-id) s/Int})
+
+(s/defschema CreateInvitationResponse
+  {:success s/Bool
+   (s/optional-key :invitation/id) s/Int
+   (s/optional-key :errors) [s/Any]})
+
+(s/defschema InvitationResponse
+  {(s/optional-key :invitation/id) s/Int
+   :invitation/name s/Str
+   :invitation/email s/Str
+   :invitation/invited-by schema-base/UserWithAttributes
+   (s/optional-key :invitation/invited-user) schema-base/UserWithAttributes
+   :invitation/created DateTime
+   (s/optional-key :invitation/sent) DateTime
+   (s/optional-key :invitation/accepted) DateTime
+   (s/optional-key :invitation/workflow) {:workflow/id s/Int}})
+
+(s/defschema AcceptInvitationResponse
+  {:success s/Bool
+   (s/optional-key :errors) [s/Any]
+   (s/optional-key :invitation/workflow) {:workflow/id s/Int}})
+
+(def invitations-api
+  (context "/invitations" []
+    :tags ["invitations"]
+
+    (GET "/" []
+      :summary "Get invitations"
+      :roles +admin-read-roles+
+      :query-params [{sent :- (describe s/Bool "whether to include sent invitations") nil}
+                     {accepted :- (describe s/Bool "whether to include accepted invitations") nil}]
+      :return [InvitationResponse]
+      (ok (invitation/get-invitations (merge {:userid (getx-user-id)}
+                                             (when (some? sent) {:sent sent})
+                                             (when (some? accepted) {:accepted accepted})))))
+
+    (POST "/create" []
+      :summary "Create an invitation. The invitation will be sent asynchronously to the recipient."
+      :roles +admin-write-roles+
+      :body [command CreateInvitationCommand]
+      :return CreateInvitationResponse
+      (ok (invitation/create-invitation! (assoc command :userid (getx-user-id)))))
+
+    (POST "/accept-invitation" []
+      :summary "Acecpt an invitation. The invitation token will be spent."
+      :roles #{:logged-in}
+      :query-params [{token :- (describe s/Str "secret token of the invitation") false}]
+      :return AcceptInvitationResponse
+      (ok (invitation/accept-invitation! {:userid (getx-user-id) :token token})))))
diff --git a/src/clj/rems/api/services/form.clj b/src/clj/rems/api/services/form.clj
index 28896b9cc..477c7103b 100644
--- a/src/clj/rems/api/services/form.clj
+++ b/src/clj/rems/api/services/form.clj
@@ -36,11 +36,11 @@
                                 :fi "title"}}
          (migrate-title {:form/title "title"} [:en :fi]))))
 
-(defn create-form! [user-id form]
+(defn create-form! [userid form]
   (let [organization (:organization form)]
     (util/check-allowed-organization! organization)
     (or (validation-error form)
-        (let [form-id (form/save-form-template! user-id (migrate-title form (:languages env)))]
+        (let [form-id (form/save-form-template! userid (migrate-title form (:languages env)))]
           ;; reset-cache! not strictly necessary since forms don't depend on anything, but here for consistency
           (dependencies/reset-cache!)
           {:success (not (nil? form-id))
@@ -59,13 +59,13 @@
   (->> (form/get-form-templates filters)
        (mapv join-dependencies)))
 
-(defn edit-form! [user-id form]
+(defn edit-form! [userid form]
   ;; need to check both previous and new organization
   (util/check-allowed-organization! (:organization (get-form-template (:form/id form))))
   (util/check-allowed-organization! (:organization form))
   (or (dependencies/in-use-error {:form/id (:form/id form)})
       (validation-error form)
-      (do (form/edit-form-template! user-id form)
+      (do (form/edit-form-template! userid form)
           {:success true})))
 
 (defn set-form-enabled! [{:keys [id enabled]}]
diff --git a/src/clj/rems/api/services/invitation.clj b/src/clj/rems/api/services/invitation.clj
new file mode 100644
index 000000000..ced919aec
--- /dev/null
+++ b/src/clj/rems/api/services/invitation.clj
@@ -0,0 +1,88 @@
+(ns rems.api.services.invitation
+  (:require [rems.api.services.util :as util]
+            [rems.db.applications :as applications]
+            [rems.db.invitation :as invitation]
+            [rems.db.users :as users]
+            [rems.db.workflow :as workflow]
+            [rems.email.core :as email]
+            [medley.core :refer [update-existing]]
+            [rems.util :refer [secure-token]]))
+
+(defn- join-dependencies [invitation]
+  (when invitation
+    (-> invitation
+        (update-existing :invitation/invited-by users/join-user)
+        (update-existing :invitation/invited-user users/join-user))))
+
+(defn- apply-user-permissions [userid invitation]
+  (dissoc invitation :invitation/token))
+
+(defn- invalid-invitation-type-error [cmd]
+  (when-not (:workflow-id cmd) ; so far we only support invitation to workflow
+    {:success false
+     :errors [{:type :t.accept-invitation.errors/invalid-invitation-type
+               :workflow-id (:workflow-id cmd)}]}))
+
+(defn- invalid-workflow-error [cmd]
+  (when-let [workflow-id (:workflow-id cmd)]
+    (if-let [workflow (workflow/get-workflow workflow-id)]
+      ;; TODO: check for workflow status, or perhaps it's ok to invite to any workflow?
+      (let [organization (:organization workflow)]
+        (util/check-allowed-organization! organization))
+      {:success false
+       :errors [{:type :t.accept-invitation.errors/invalid-workflow :workflow-id workflow-id}]})))
+
+(defn get-invitations-full [cmd]
+  (->> cmd
+       invitation/get-invitations
+       (mapv join-dependencies)))
+
+(defn get-invitations [cmd]
+  (->> cmd
+       get-invitations-full
+       (mapv (partial apply-user-permissions (:userid cmd)))))
+
+(defn get-invitation-full [id]
+  (->> {:ids [id]}
+       get-invitations-full
+       first))
+
+(defn get-invitation [id]
+  (->> {:ids [id]}
+       get-invitations
+       first))
+
+
+(defn create-invitation! [cmd]
+  (or (invalid-invitation-type-error cmd)
+      (invalid-workflow-error cmd)
+      (let [id (invitation/create-invitation! (merge {:invitation/name (:name cmd)
+                                                      :invitation/email (:email cmd)
+                                                      :invitation/token (secure-token)
+                                                      :invitation/invited-by {:userid (:userid cmd)}}
+                                                     (when-let [workflow-id (:workflow-id cmd)]
+                                                       {:invitation/workflow {:workflow/id workflow-id}})))]
+        (when id
+          (email/generate-invitation-emails! (get-invitations-full {:ids [id]})))
+        {:success (not (nil? id))
+         :invitation/id id})))
+
+(defn accept-invitation! [{:keys [userid token]}]
+  (if-let [invitation (first (invitation/get-invitations {:token token}))]
+    (if-let [workflow-id (get-in invitation [:invitation/workflow :workflow/id])]
+      (let [workflow (workflow/get-workflow workflow-id)
+            handlers (set (map :userid (get-in workflow [:workflow :handlers])))]
+        (if (contains? handlers userid)
+          {:success false
+           :errors [{:key :t.accept-invitation.errors.already-member/workflow}]}
+          (do
+            (workflow/edit-workflow! {:id workflow-id
+                                      :handlers (conj handlers userid)})
+            (invitation/accept-invitation! userid token)
+            (applications/reload-cache!)
+            {:success true
+             :invitation/workflow {:workflow/id (:id workflow)}})))
+      {:success false
+       :errors [{:key :t.accept-invitation.errors/invalid-invitation-type}]})
+    {:success false
+     :errors [{:key :t.accept-invitation.errors/invalid-token :token token}]}))
diff --git a/src/clj/rems/api/services/workflow.clj b/src/clj/rems/api/services/workflow.clj
index c2a23e214..88ac7c7dd 100644
--- a/src/clj/rems/api/services/workflow.clj
+++ b/src/clj/rems/api/services/workflow.clj
@@ -39,6 +39,7 @@
     (update-in workflow [:workflow :handlers] #(map :userid %))
     workflow))
 
+;; TODO: use rems.db.workflow/edit-workflow! and don't go directly to db fns, same for other fns
 (defn edit-workflow! [{:keys [id organization title handlers]}]
   (let [workflow (unrich-workflow (workflow/get-workflow id))
         workflow-body (cond-> (:workflow workflow)
diff --git a/src/clj/rems/api/workflows.clj b/src/clj/rems/api/workflows.clj
index 02abdb598..f3213214b 100644
--- a/src/clj/rems/api/workflows.clj
+++ b/src/clj/rems/api/workflows.clj
@@ -51,7 +51,7 @@
       :roles +admin-write-roles+
       :body [command CreateWorkflowCommand]
       :return CreateWorkflowResponse
-      (ok (workflow/create-workflow! (assoc command :user-id (getx-user-id)))))
+      (ok (workflow/create-workflow! (assoc command :userid (getx-user-id)))))
 
     (PUT "/edit" []
       :summary "Edit workflow title and handlers"
diff --git a/src/clj/rems/application/model.clj b/src/clj/rems/application/model.clj
index c60ff43f0..fa431fee6 100644
--- a/src/clj/rems/application/model.clj
+++ b/src/clj/rems/application/model.clj
@@ -752,12 +752,12 @@
       (dissoc ::latest-review-request-by-user ::latest-decision-request-by-user)
       (dissoc :application/past-members)))
 
-(defn- personalize-todo [application user-id]
+(defn- personalize-todo [application userid]
   (cond-> application
-    (contains? (::latest-review-request-by-user application) user-id)
+    (contains? (::latest-review-request-by-user application) userid)
     (assoc :application/todo :waiting-for-your-review)
 
-    (contains? (::latest-decision-request-by-user application) user-id)
+    (contains? (::latest-decision-request-by-user application) userid)
     (assoc :application/todo :waiting-for-your-decision)))
 
 (defn- visible-attachment-ids [application]
@@ -788,22 +788,22 @@
   (or (contains? roles :applicant)
       (contains? roles :member)))
 
-(defn see-application? [application user-id]
-  (let [permissions (permissions/user-roles application user-id)]
+(defn see-application? [application userid]
+  (let [permissions (permissions/user-roles application userid)]
     (if (= :application.state/draft (:application/state application))
       (user-is-applicant-or-member permissions)
       (not= #{:everyone-else} permissions))))
 
-(defn apply-user-permissions [application user-id]
-  (let [see-application? (see-application? application user-id)
-        roles (permissions/user-roles application user-id)
-        permissions (permissions/user-permissions application user-id)
+(defn apply-user-permissions [application userid]
+  (let [see-application? (see-application? application userid)
+        roles (permissions/user-roles application userid)
+        permissions (permissions/user-permissions application userid)
         see-everything? (contains? permissions :see-everything)]
     (when see-application?
       (-> (if see-everything?
             application
             (hide-sensitive-information application))
-          (personalize-todo user-id)
+          (personalize-todo userid)
           (hide-non-public-information)
           (apply-privacy roles)
           (hide-attachments)
diff --git a/src/clj/rems/db/invitation.clj b/src/clj/rems/db/invitation.clj
new file mode 100644
index 000000000..53cadc49c
--- /dev/null
+++ b/src/clj/rems/db/invitation.clj
@@ -0,0 +1,63 @@
+(ns rems.db.invitation
+  (:require [rems.db.core :as db]
+            [rems.json :as json]
+            [rems.schema-base :as schema-base]
+            [schema.coerce :as coerce]
+            [schema.core :as s])
+  (:import [org.joda.time DateTime]))
+
+(def InvitationData
+  {(s/optional-key :invitation/id) s/Int
+   :invitation/name s/Str
+   :invitation/email s/Str
+   :invitation/token s/Str
+   :invitation/invited-by schema-base/User
+   (s/optional-key :invitation/invited-user) schema-base/User
+   :invitation/created DateTime
+   (s/optional-key :invitation/sent) DateTime
+   (s/optional-key :invitation/accepted) DateTime
+   (s/optional-key :invitation/workflow) {:workflow/id s/Int}})
+
+(def ^:private validate-InvitationData
+  (s/validator InvitationData))
+
+(defn create-invitation! [data]
+  (let [amended (assoc data :invitation/created (DateTime/now))
+        json (json/generate-string (validate-InvitationData amended))]
+    (-> {:invitationdata json}
+        db/add-invitation!
+        :id)))
+
+(def ^:private coerce-InvitationData
+  (coerce/coercer! InvitationData json/coercion-matcher))
+
+(defn- fix-row-from-db [row]
+  (-> (:invitationdata row)
+      json/parse-string
+      coerce-InvitationData
+      (assoc :invitation/id (:id row))))
+
+(defn get-invitations
+  [{:keys [workflow-id ids token sent accepted]}]
+  (cond->> (db/get-invitations {:ids ids :token token})
+    true (map fix-row-from-db)
+    workflow-id (filter (comp #{workflow-id} :workflow/id :invitation/workflow))
+    (some? sent) ((if sent filter remove) :invitation/sent)
+    (some? accepted) ((if accepted filter remove) :invitation/accepted)))
+
+(defn accept-invitation! [userid token]
+  (when-let [invitation (first (get-invitations {:token token}))]
+    (let [amended (merge (dissoc invitation :invitation/id)
+                         {:invitation/invited-user {:userid userid}
+                          :invitation/accepted (DateTime/now)})
+          json (json/generate-string (validate-InvitationData amended))]
+      (db/set-invitation! {:id (:invitation/id invitation)
+                           :invitationdata json}))))
+
+(defn mail-sent! [id]
+  (when-let [invitation (first (get-invitations {:ids [id]}))]
+    (let [amended (merge (dissoc invitation :invitation/id)
+                         {:invitation/sent (DateTime/now)})
+          json (json/generate-string (validate-InvitationData amended))]
+      (db/set-invitation! {:id (:invitation/id invitation)
+                           :invitationdata json}))))
diff --git a/src/clj/rems/db/user_settings.clj b/src/clj/rems/db/user_settings.clj
index dac7fa0bc..f3809fc0c 100644
--- a/src/clj/rems/db/user_settings.clj
+++ b/src/clj/rems/db/user_settings.clj
@@ -21,7 +21,7 @@
 (s/defschema DbUserSettings
   {:language s/Keyword
    (s/optional-key :notification-email) (s/maybe s/Str)
-   (s/optional-key :ega) {:api-key-expiration-date DateTime}})
+   (s/optional-key :ega) {(s/optional-key :api-key-expiration-date) DateTime}})
 
 (def ^:private validate-user-settings
   (s/validator UserSettings))
diff --git a/src/clj/rems/db/users.clj b/src/clj/rems/db/users.clj
index 07d913891..f0162ed6f 100644
--- a/src/clj/rems/db/users.clj
+++ b/src/clj/rems/db/users.clj
@@ -123,3 +123,7 @@
       format-user
       ;; in case user attributes were not found, return at least the userid
       (assoc :userid userid)))
+
+(defn join-user [x]
+  (when-let [userid (:userid x)]
+    (get-user userid)))
diff --git a/src/clj/rems/db/workflow.clj b/src/clj/rems/db/workflow.clj
index 93e481528..36972a7c4 100644
--- a/src/clj/rems/db/workflow.clj
+++ b/src/clj/rems/db/workflow.clj
@@ -19,13 +19,13 @@
 (def ^:private validate-workflow-body
   (s/validator WorkflowBody))
 
-(defn create-workflow! [{:keys [user-id organization type title handlers forms]}]
+(defn create-workflow! [{:keys [userid organization type title handlers forms]}]
   (let [body {:type type
               :handlers handlers
               :forms forms}]
     (:id (db/create-workflow! {:organization (:organization/id organization)
-                               :owneruserid user-id
-                               :modifieruserid user-id
+                               :owneruserid userid
+                               :modifieruserid userid
                                :title title
                                :workflow (json/generate-string
                                           (validate-workflow-body body))}))))
@@ -57,3 +57,20 @@
   (when (some #(contains? (set (map :userid (get-in % [:workflow :handlers]))) userid)
               (get-workflows nil))
     #{:handler}))
+
+(defn- unrich-workflow [workflow]
+  ;; TODO: service does this too
+  ;; TODO: keep handlers always in the same format, to avoid this conversion (we can ignore extra keys)
+  (if (get-in workflow [:workflow :handlers])
+    (update-in workflow [:workflow :handlers] #(map :userid %))
+    workflow))
+
+(defn edit-workflow! [{:keys [id organization title handlers]}]
+  (let [workflow (unrich-workflow (get-workflow id))
+        workflow-body (cond-> (:workflow workflow)
+                        handlers (assoc :handlers handlers))]
+    (db/edit-workflow! {:id (or id (:id workflow))
+                        :title (or title (:title workflow))
+                        :organization (or (:organization/id organization) (get-in workflow [:organization :organization/id]))
+                        :workflow (json/generate-string workflow-body)}))
+  {:success true})
diff --git a/src/clj/rems/email/core.clj b/src/clj/rems/email/core.clj
index a5d98544a..7dcbeea97 100644
--- a/src/clj/rems/email/core.clj
+++ b/src/clj/rems/email/core.clj
@@ -10,6 +10,7 @@
             [rems.application.model]
             [rems.config :refer [env]]
             [rems.db.applications :as applications]
+            [rems.db.invitation :as invitation]
             [rems.db.outbox :as outbox]
             [rems.db.user-settings :as user-settings]
             [rems.db.users :as users]
@@ -53,6 +54,20 @@
                      (remove nil?))]
     (enqueue-email! email)))
 
+(defn- render-invitation-template [invitation]
+  (let [lang (:default-language env)] ; we don't know the preferred languages here since there is no user
+    (cond (:invitation/workflow invitation)
+          (let [workflow (workflow/get-workflow (get-in invitation [:invitation/workflow :workflow/id]))]
+            (assert workflow "Can't send invitation, missing workflow")
+            (template/workflow-handler-invitation-email lang invitation workflow)))))
+
+(defn generate-invitation-emails! [invitations]
+  (doseq [invitation invitations
+          :when (not (:invitation/sent invitation))
+          :let [email (render-invitation-template invitation)]]
+    (invitation/mail-sent! (:invitation/id invitation))
+    (enqueue-email! email)))
+
 ;;; Email poller
 
 ;; You can test email sending by:
diff --git a/src/clj/rems/email/template.clj b/src/clj/rems/email/template.clj
index 6aab91fa5..d23897859 100644
--- a/src/clj/rems/email/template.clj
+++ b/src/clj/rems/email/template.clj
@@ -281,3 +281,22 @@
                               (application-util/get-member-name reviewer)
                               list
                               (str (:public-url env) "actions"))})))))
+
+(defn workflow-handler-invitation-email [lang invitation workflow]
+  (with-language lang
+    (fn []
+      (when workflow
+        {:to (:invitation/email invitation)
+         :subject (text-format :t.email.workflow-handler-invitation/subject
+                               (:invitation/name invitation)
+                               (get-in invitation [:invitation/invited-by :name])
+                               (:title workflow)
+                               (invitation-link (:invitation/token invitation)))
+         :body (str
+                (text-format :t.email.workflow-handler-invitation/message
+                             (:invitation/name invitation)
+                             (get-in invitation [:invitation/invited-by :name])
+                             (:title workflow)
+                             (invitation-link (:invitation/token invitation)))
+                (text :t.email/regards)
+                (text :t.email/footer))}))))
diff --git a/src/clj/rems/handler.clj b/src/clj/rems/handler.clj
index 32e013d96..1ea3dbb0d 100644
--- a/src/clj/rems/handler.clj
+++ b/src/clj/rems/handler.clj
@@ -6,6 +6,7 @@
             [mount.core :as mount]
             [rems.api :refer [api-routes]]
             [rems.api.services.attachment :as attachment]
+            [rems.api.services.invitation :as invitation]
             [rems.api.services.licenses :as licenses]
             [rems.api.util :as api-util]
             [rems.context :as context]
@@ -45,7 +46,10 @@
 
 (defroutes redirects
   (GET "/accept-invitation" [token]
-    (redirect (str "/application/accept-invitation/" token)))
+    (if-let [invitation (first (invitation/get-invitations {:token token}))]
+      (cond (:invitation/workflow invitation)
+            (redirect (str "/invitation/accept-invitation?type=workflow&token=" token)))
+      (redirect (str "/application/accept-invitation/" token))))
 
   (GET "/apply-for" [resource] ;; can specify multiple resources
     (if (vector? resource)
diff --git a/src/cljs/rems/accept_invitation.cljs b/src/cljs/rems/accept_invitation.cljs
new file mode 100644
index 000000000..c09976cd7
--- /dev/null
+++ b/src/cljs/rems/accept_invitation.cljs
@@ -0,0 +1,52 @@
+(ns rems.accept-invitation
+  "Page for accepting invitations v2. See `rems.actions.accept-invitation` for the v1."
+  (:require [re-frame.core :as rf]
+            [rems.atoms :refer [document-title]]
+            [rems.flash-message :as flash-message]
+            [rems.spinner :as spinner]
+            [rems.text :refer [text text-format]]
+            [rems.util :refer [navigate! post!]]))
+
+(rf/reg-event-fx
+ ::enter-page
+ (fn [{:keys [db]} [_ type token]]
+   {::accept-invitation [type token]}))
+
+(rf/reg-fx
+ ::accept-invitation
+ (fn [[type token]]
+   (let [error-handler (fn [response]
+                         ((flash-message/default-error-handler :top [text :t.accept-invitation/header]) response)
+                         (navigate! "/catalogue"))]
+     (post! "/api/invitations/accept-invitation"
+            {:url-params {:token token}
+             :handler (fn [response]
+                        (let [error (first (:errors response))]
+                          (cond
+                            (:success response)
+                            (do
+                              (flash-message/show-success! :top [text (case type
+                                                                        :workflow :t.accept-invitation.success/workflow)])
+                              (case type
+                                :workflow (navigate! (str "/administration/workflows/" (get-in response [:invitation/workflow :workflow/id])))))
+
+                            (= :already-member (:type error))
+                            (do
+                              (flash-message/show-success! :top [text (case type
+                                                                        :workflow :t.accept-invitation.errors.already-member/workflow)])
+                              (case type
+                                :workflow
+                                (navigate! (str "/administration/workflows/" (get-in response [:invitation/workflow :workflow/id])))))
+
+                            (= :t.actions.errors/invalid-token (:type error))
+                            (do
+                              (flash-message/show-error! :top [text-format :t.accept-invitation.errors/invalid-token (:token error)])
+                              (navigate! "/catalogue")) ; generic front-page, user is possibly not a handler/admin
+
+                            :else (error-handler response))))
+             :error-handler error-handler}))))
+
+(defn accept-invitation-page []
+  [:div
+   [document-title (text :t.accept-invitation/title)]
+   [spinner/big]])
diff --git a/src/cljs/rems/actions/accept_invitation.cljs b/src/cljs/rems/actions/accept_invitation.cljs
index aec5ca2ad..a1186d7ce 100644
--- a/src/cljs/rems/actions/accept_invitation.cljs
+++ b/src/cljs/rems/actions/accept_invitation.cljs
@@ -9,10 +9,7 @@
 (rf/reg-event-fx
  ::enter-page
  (fn [{:keys [db]} [_ token]]
-   {:db (assoc db ::token token)
-    ::accept-invitation token}))
-
-(rf/reg-sub ::token (fn [db] (::token db "")))
+   {::accept-invitation token}))
 
 (rf/reg-fx
  ::accept-invitation
diff --git a/src/cljs/rems/spa.cljs b/src/cljs/rems/spa.cljs
index 6b125ad7c..d8747dd73 100644
--- a/src/cljs/rems/spa.cljs
+++ b/src/cljs/rems/spa.cljs
@@ -8,6 +8,7 @@
             [reagent.core :as r]
             [rems.actions :refer [actions-page]]
             [rems.actions.accept-invitation :refer [accept-invitation-page]]
+            [rems.accept-invitation :as accept-invitation]
             [rems.administration.blacklist :refer [blacklist-page]]
             [rems.administration.catalogue-item :refer [catalogue-item-page]]
             [rems.administration.catalogue-items :refer [catalogue-items-page]]
@@ -250,6 +251,7 @@
    :applications applications-page
    :extra-pages extra-pages
    :profile profile-page
+   :rems.accept-invitation/accept-invitation-page accept-invitation/accept-invitation-page
    :rems.actions/accept-invitation accept-invitation-page
    :rems.administration/blacklist blacklist-page
    :rems.administration/catalogue-item catalogue-item-page
@@ -359,6 +361,12 @@
   (rf/dispatch [:rems.actions/enter-page])
   (rf/dispatch [:set-active-page :actions]))
 
+(secretary/defroute "/invitation/accept-invitation" {{token :token type :type} :query-params}
+  (rf/dispatch [:after-translations-are-loaded
+                #(do
+                   (rf/dispatch [:rems.accept-invitation/enter-page (keyword type) token])
+                   (rf/dispatch [:set-active-page :rems.accept-invitation/accept-invitation-page]))]))
+
 (secretary/defroute "/application/accept-invitation/:invitation-token" [invitation-token]
   (rf/dispatch [:after-translations-are-loaded
                 #(do


Test patch:
diff --git a/src/clj/rems/db/test_data_helpers.clj b/src/clj/rems/db/test_data_helpers.clj
index c7c095482..b04909cc5 100644
--- a/src/clj/rems/db/test_data_helpers.clj
+++ b/src/clj/rems/db/test_data_helpers.clj
@@ -146,7 +146,7 @@
   (let [actor (or actor (create-owner!))
         result (with-user actor
                  (workflow/create-workflow!
-                  {:user-id actor
+                  {:userid actor
                    :organization (or organization (ensure-default-organization!))
                    :title (or title "")
                    :type (or type :workflow/master)
diff --git a/test/clj/rems/api/services/test_invitation.clj b/test/clj/rems/api/services/test_invitation.clj
new file mode 100644
index 000000000..c61aa6eed
--- /dev/null
+++ b/test/clj/rems/api/services/test_invitation.clj
@@ -0,0 +1,113 @@
+(ns ^:integration rems.api.services.test-invitation
+  (:require [clojure.test :refer :all]
+            [rems.api.services.invitation :as invitation]
+            [rems.db.test-data-helpers :as test-helpers]
+            [rems.db.testing :refer [caches-fixture rollback-db-fixture test-db-fixture]]
+            [rems.email.core :as email]
+            [rems.testing-util :refer [fixed-time-fixture with-user]])
+  (:import [org.joda.time DateTime DateTimeUtils DateTimeZone]))
+
+(def invitation-time (DateTime. 10000 DateTimeZone/UTC))
+(def acceptance-time (DateTime. 20000 DateTimeZone/UTC))
+
+(use-fixtures
+  :once
+  test-db-fixture
+  caches-fixture
+  (fixed-time-fixture invitation-time))
+
+(use-fixtures :each rollback-db-fixture)
+
+(deftest test-crud-invitation
+  (test-helpers/create-user! {:eppn "owner" :commonName "owner" :mail "owner@example.com"} :owner)
+  (test-helpers/create-user! {:eppn "new-handler" :commonName "New Handler" :mail "new-handler@example.com"})
+  (let [workflow-id (test-helpers/create-workflow! {})]
+    (with-user "owner"
+      (testing "before creating invitations"
+        (is (= [] (invitation/get-invitations nil))))
+
+      (testing "creating invitations"
+        (testing "without any type"
+          (is (= {:success false
+                  :errors
+                  [{:type :t.accept-invitation.errors/invalid-invitation-type :workflow-id nil}]}
+                 (invitation/create-invitation! {:userid "owner"
+                                                 :name "Dorothy Vaughan"
+                                                 :email "dorothy.vaughan@nasa.gov"}))))
+
+        (testing "with invalid workflow"
+          (is (= {:success false
+                  :errors [{:type :t.accept-invitation.errors/invalid-workflow :workflow-id 42}]}
+                 (invitation/create-invitation! {:userid "owner"
+                                                 :name "Dorothy Vaughan"
+                                                 :email "dorothy.vaughan@nasa.gov"
+                                                 :workflow-id 42}))))
+
+        (testing "success"
+          (let [sent-email (atom nil)]
+            (with-redefs [email/enqueue-email! (fn [& args] (reset! sent-email args))]
+              (let [invitation (invitation/create-invitation! {:userid "owner"
+                                                               :name "Dorothy Vaughan"
+                                                               :email "dorothy.vaughan@nasa.gov"
+                                                               :workflow-id workflow-id})
+                    invitation-id (:invitation/id invitation)]
+                (is (= {:success true} (dissoc invitation :invitation/id)))
+                (is (number? invitation-id))
+                (is (= "dorothy.vaughan@nasa.gov" (:to (first @sent-email))))
+
+                (testing "after creating invitations"
+                  (let [invitations (invitation/get-invitations nil)]
+                    (is (= [{:invitation/name "Dorothy Vaughan"
+                             :invitation/email "dorothy.vaughan@nasa.gov"
+                             :invitation/invited-by {:userid "owner"
+                                                     :name "Owner"
+                                                     :email "owner@example.com"}
+                             :invitation/created invitation-time
+                             :invitation/sent invitation-time
+                             :invitation/workflow {:workflow/id workflow-id}}]
+                           (mapv #(dissoc % :invitation/token :invitation/id)
+                                 invitations)))
+                    (is (contains? (set (map :invitation/id invitations)) invitation-id))
+                    (is (= :not-present (:invitation/token (first invitations) :not-present)) "must not reveal token")
+
+                    (testing "accept invitation with fake code"
+                      (let [response (invitation/accept-invitation! {:userid "new-handler"
+                                                                     :token "doesnotexist"})]
+                        (is (= {:success false
+                                :errors [{:key :t.accept-invitation.errors/invalid-token :token "doesnotexist"}]}
+                               response))))
+
+                    (DateTimeUtils/setCurrentMillisFixed (.getMillis acceptance-time))
+
+                    (testing "accept invitation"
+                      (let [token (-> invitation-id invitation/get-invitation-full first :invitation/token)
+                            response (invitation/accept-invitation! {:userid "new-handler"
+                                                                     :token token})]
+                        (is (= {:success true
+                                :invitation/workflow {:workflow/id workflow-id}}
+                               response))))
+
+                    (testing "after accepting invitation"
+                      (let [invitations (invitation/get-invitations nil)]
+                        (is (= [{:invitation/name "Dorothy Vaughan"
+                                 :invitation/email "dorothy.vaughan@nasa.gov"
+                                 :invitation/invited-user {:userid "new-handler"
+                                                           :name "New Handler"
+                                                           :email "new-handler@example.com"}
+                                 :invitation/invited-by {:userid "owner"
+                                                         :name "Owner"
+                                                         :email "owner@example.com"}
+                                 :invitation/created invitation-time
+                                 :invitation/sent invitation-time
+                                 :invitation/accepted acceptance-time
+                                 :invitation/workflow {:workflow/id workflow-id}}]
+                               (mapv #(dissoc % :invitation/token :invitation/id)
+                                     invitations)))))
+
+                    (testing "accept invitation again"
+                      (let [token (-> invitation-id invitation/get-invitation-full first :invitation/token)
+                            response (invitation/accept-invitation! {:userid "new-handler"
+                                                                     :token token})]
+                        (is (= {:success false
+                                :errors [{:key :t.accept-invitation.errors.already-member/workflow}]}
+                               response))))))))))))))
diff --git a/test/clj/rems/api/test_invitations.clj b/test/clj/rems/api/test_invitations.clj
new file mode 100644
index 000000000..eef77406b
--- /dev/null
+++ b/test/clj/rems/api/test_invitations.clj
@@ -0,0 +1,148 @@
+(ns ^:integration rems.api.test-invitations
+  (:require [clojure.test :refer :all]
+            [rems.api.services.invitation :as invitation]
+            [rems.api.testing :refer :all]
+            [rems.db.applications]
+            [rems.db.test-data :as test-data]
+            [rems.db.test-data-helpers :as test-helpers]
+            [rems.json]
+            [rems.testing-util :refer [with-user]]
+            [ring.mock.request :refer :all]))
+
+(use-fixtures
+  :each
+  api-fixture
+  ;; TODO should this fixture have a name?
+  (fn [f]
+    (test-data/create-test-api-key!)
+    (test-data/create-test-users-and-roles!)
+    (f)))
+
+(deftest test-invitation-flow
+  (let [workflow-id (test-helpers/create-workflow! {})]
+    (testing "before invitation"
+      (is (= [] (api-call :get "/api/invitations" nil test-data/+test-api-key+ "owner"))))
+
+    (testing "create invitation"
+      (let [body (api-call :post "/api/invitations/create" {:name "Katherine Johnson"
+                                                            :email "katherine.johnson@nasa.gov"
+                                                            :workflow-id workflow-id}
+                           test-data/+test-api-key+ "owner")]
+        (is (= {:success true}
+               (dissoc body :invitation/id)))))
+
+    (testing "after invitation"
+      (is (= [] (api-call :get "/api/invitations?sent=false" nil test-data/+test-api-key+ "owner"))
+          "invitation has already been sent")
+      (is (= [] (api-call :get "/api/invitations?accepted=true" nil test-data/+test-api-key+ "owner"))
+          "invitation has not been accepted")
+
+      (let [invitation (first (api-call :get "/api/invitations" nil test-data/+test-api-key+ "owner"))
+            invitation2 (first (api-call :get "/api/invitations?sent=true" nil test-data/+test-api-key+ "owner"))]
+        (is (= {:invitation/name "Katherine Johnson"
+                :invitation/email "katherine.johnson@nasa.gov"
+                :invitation/invited-by {:email "owner@example.com"
+                                        :userid "owner"
+                                        :name "Owner"}
+                :invitation/workflow {:workflow/id workflow-id}}
+               (dissoc invitation
+                       :invitation/id ; always different from sequence
+                       :invitation/created ; should be checked in service
+                       :invitation/sent)
+               (dissoc invitation2
+                       :invitation/id ; always different from sequence
+                       :invitation/created ; should be checked in service
+                       :invitation/sent)))   ; should be checked in service
+
+        (testing "accepting"
+          (let [token (:invitation/token (invitation/get-invitation-full (:invitation/id invitation)))] ; not available in API
+            (is (= {:success true
+                    :invitation/workflow {:workflow/id workflow-id}}
+                   (api-call :post "/api/invitations/accept-invitation" {:token token} test-data/+test-api-key+ "katherine"))))
+          (test-helpers/create-user! {:eppn "katherine" :mail "katherine.johnson@nasa.gov" :commonName "Katherine Johnson"})
+
+          (let [accepted-invitation (first (api-call :get "/api/invitations" nil test-data/+test-api-key+ "owner"))]
+            (is (= {:invitation/name "Katherine Johnson"
+                    :invitation/email "katherine.johnson@nasa.gov"
+                    :invitation/invited-by {:email "owner@example.com"
+                                            :userid "owner"
+                                            :name "Owner"}
+                    :invitation/invited-user {:email "katherine.johnson@nasa.gov"
+                                              :userid "katherine"
+                                              :name "Katherine Johnson"}
+                    :invitation/workflow {:workflow/id workflow-id}}
+                   (dissoc accepted-invitation :invitation/id :invitation/created :invitation/sent :invitation/accepted)))))))))
+
+(deftest test-invitations-api-security
+  (api-call :post "/api/organizations/create"
+            {:organization/id "test-organization"
+             :organization/name {:fi "Testiorganisaatio"
+                                 :en "Test Organization"}
+             :organization/short-name {:fi "ORG"
+                                       :en "ORG"}
+             :organization/owners [{:userid "organization-owner2"}]
+             :organization/review-emails []}
+            "42" "owner")
+  (test-helpers/create-user! {:eppn "katherine" :mail "katherine.johnson@nasa.gov" :commonName "Katherine Johnson"})
+  (let [workflow-id (test-helpers/create-workflow! {:organization {:organization/id "test-organization"}})
+        invitation-id (:invitation/id (with-user "owner"
+                                        (invitation/create-invitation! {:userid "owner"
+                                                                        :name "Katherine Johnson"
+                                                                        :email "katherine.johnson@nasa.gov"
+                                                                        :workflow-id workflow-id})))
+        token (:invitation/token (with-user "owner" (invitation/get-invitation-full invitation-id)))]
+    (testing "without authentication"
+      (testing "list"
+        (let [response (api-response :get "/api/invitations")]
+          (is (response-is-unauthorized? response))
+          (is (= "unauthorized" (read-body response)))))
+
+      (testing "create"
+        (let [response (api-response :post "/api/invitations/create"
+                                     {:name "Katherine Johnson"
+                                      :email "katherine.johnson@nasa.gov"
+                                      :workflow-id workflow-id})]
+          (is (response-is-unauthorized? response))
+          (is (= "Invalid anti-forgery token" (read-body response)))))
+
+      (testing "accept-invitation"
+        (let [response (api-response :post (str "/api/invitations/accept-invitation?token=" token) nil)]
+          (is (response-is-unauthorized? response))
+          (is (= "Invalid anti-forgery token" (read-body response))))))
+
+    (testing "create"
+      (testing "without owner role"
+        (let [response (api-response :post "/api/invitations/create"
+                                     {:name "Katherine Johnson"
+                                      :email "katherine.johnson@nasa.gov"
+                                      :workflow-id workflow-id}
+                                     "42" "alice")]
+          (is (response-is-forbidden? response))
+          (is (= "forbidden" (read-body response)))))
+
+      (testing "for an owner of wrong organization"
+        (let [response (api-response :post "/api/invitations/create"
+                                     {:name "Katherine Johnson"
+                                      :email "katherine.johnson@nasa.gov"
+                                      :workflow-id workflow-id}
+                                     "42" "organization-owner1")]
+          (is (response-is-forbidden? response))
+          (is (= "forbidden" (read-body response)))))
+
+      (testing "given owner role"
+        (api-call :post "/api/invitations/create"
+                  {:name "Katherine Johnson"
+                   :email "katherine.johnson@nasa.gov"
+                   :workflow-id workflow-id}
+                  "42" "owner"))
+
+      (testing "given organization-owner role"
+        (api-call :post "/api/invitations/create"
+                  {:name "Katherine Johnson"
+                   :email "katherine.johnson@nasa.gov"
+                   :workflow-id workflow-id}
+                  "42" "organization-owner2")))
+
+    (testing "accept-invitation"
+      (let [response (api-response :post (str "/api/invitations/accept-invitation?token=" token) nil "42" "katherine")]
+        (is (response-is-ok? response))))))
diff --git a/test/clj/rems/db/test_workflow.clj b/test/clj/rems/db/test_workflow.clj
index e1597903a..d7a0caa17 100644
--- a/test/clj/rems/db/test_workflow.clj
+++ b/test/clj/rems/db/test_workflow.clj
@@ -14,3 +14,44 @@
     (test-helpers/create-user! {:eppn "handler-user"})
     (test-helpers/create-workflow! {:handlers ["handler-user"]})
     (is (= #{:handler} (workflow/get-all-workflow-roles "handler-user")))))
+
+(deftest test-crud-workflow
+  (testing "creating"
+    (let [id (workflow/create-workflow! {:userid "owner"
+                                         :organization {:organization/id "abc"}
+                                         :type :workflow/default
+                                         :title "test-crud-workflow-title"
+                                         :handlers ["bob" "handler"]
+                                         :forms []})]
+      (is (number? id))
+      (is (= {:archived false
+              :licenses []
+              :organization {:organization/id "abc"}
+              :title "test-crud-workflow-title"
+              :workflow {:type :workflow/default
+                         :handlers [{:userid "bob" :name nil :email nil}
+                                    {:userid "handler" :name nil :email nil}]
+                         :forms []}
+              :id id
+              :owneruserid "owner"
+              :modifieruserid "owner"
+              :enabled true}
+             (workflow/get-workflow id)))
+
+      (testing "editing"
+        (workflow/edit-workflow! {:id id
+                                  :handlers ["bob" "handler" "alice"]})
+        (is (= {:archived false
+                :licenses []
+                :organization {:organization/id "abc"}
+                :title "test-crud-workflow-title"
+                :workflow {:type :workflow/default
+                           :handlers [{:userid "bob" :name nil :email nil}
+                                      {:userid "handler" :name nil :email nil}
+                                      {:userid "alice" :name nil :email nil}]
+                           :forms []}
+                :id id
+                :owneruserid "owner"
+                :modifieruserid "owner"
+                :enabled true}
+               (workflow/get-workflow id)))))))
diff --git a/test/clj/rems/email/test_template.clj b/test/clj/rems/email/test_template.clj
index e39bb6780..cb7190977 100644
--- a/test/clj/rems/email/test_template.clj
+++ b/test/clj/rems/email/test_template.clj
@@ -4,6 +4,7 @@
             [rems.application.model :as model]
             [rems.config]
             [rems.db.user-settings :as user-settings]
+            [rems.db.workflow :as workflow]
             [rems.email.template :as template]
             [rems.locales])
   (:import (org.joda.time DateTime)))
@@ -492,3 +493,17 @@
                 :application/applicant {:userid "arnold"
                                         :email "arnold@example.com"
                                         :name "Arnold Applicant"}}]))))))
+
+(deftest test-workflow-handler-invitation-email
+  (with-redefs [rems.config/env (assoc rems.config/env :public-url "http://example.com/")]
+    (is (= {:to "hamza@institute.org"
+            :subject "Invitation to handle applications"
+            :body "Dear Hamza Handler,\n\nEliza Owner has invited you to be a handler of applications of workflow Template Workflow.\n\nYou can view the workflow at http://example.com/accept-invitation?token=secret"}
+           (template/workflow-handler-invitation-email
+            :en
+            {:invitation/name "Hamza Handler"
+             :invitation/email "hamza@institute.org"
+             :invitation/invited-by {:name "Eliza Owner"}
+             :invitation/token "secret"
+             :invitation/workflow {:workflow/id 5}}
+            {:title "Template Workflow"})))))
diff --git a/test/clj/rems/test_browser.clj b/test/clj/rems/test_browser.clj
index 35b5f94d6..7bbc42e02 100644
--- a/test/clj/rems/test_browser.clj
+++ b/test/clj/rems/test_browser.clj
@@ -14,10 +14,12 @@
             [com.rpl.specter :refer [select ALL]]
             [rems.api.services.catalogue :as catalogue]
             [rems.api.services.form :as forms]
+            [rems.api.services.invitation :as invitations]
             [rems.api.services.organizations :as organizations]
             [rems.api.services.resource :as resources]
             [rems.api.services.workflow :as workflows]
             [rems.browser-test-util :as btu]
+            [rems.common.util :refer [getx]]
             [rems.config]
             [rems.context :as context]
             [rems.db.applications :as applications]
@@ -725,6 +727,45 @@
                  last
                  (select-keys [:application/decision :application/comment :event/actor :event/type])))))))
 
+(deftest test-invite-handler
+  (testing "create test data"
+    (btu/context-assoc! :form-id (test-helpers/create-form! {:form/fields [{:field/title {:en "description" :fi "kuvaus" :sv "rubrik"}
+                                                                            :field/optional false
+                                                                            :field/type :description}]}))
+    (btu/context-assoc! :workflow-title (str "test-invite-handler " (btu/get-seed)))
+    (btu/context-assoc! :workflow-id (test-helpers/create-workflow! {:title (btu/context-get :workflow-title) :handlers []}))
+    (btu/context-assoc! :catalogue-id (test-helpers/create-catalogue-item! {:form-id (btu/context-get :form-id)
+                                                                            :workflow-id (btu/context-get :workflow-id)}))
+    (test-helpers/create-user! {:eppn "invited-person-id" :commonName "Invited Person Name"})
+    (with-user "owner"
+      (btu/context-assoc! :invitation-id (getx (invitations/create-invitation! {:userid "owner"
+                                                                                :name "Dorothy Vaughan"
+                                                                                :email "dorothy.vaughan@nasa.gov"
+                                                                                :workflow-id (btu/context-get :workflow-id)}) :invitation/id))))
+  (btu/with-postmortem
+    (testing "get invitation token"
+      (let [invitation (-> (btu/context-get :invitation-id) invitations/get-invitation-full)
+            token (:invitation/token invitation)]
+        (is (string? token))
+        (btu/context-assoc! :token token)))
+
+    (testing "accept invitation"
+      (btu/go (str (btu/get-server-url) "accept-invitation?token=" (btu/context-get :token)))
+      (is (btu/eventually-visible? {:css ".login-btn"}))
+      (btu/scroll-and-click {:css ".login-btn"})
+      (is (btu/eventually-visible? [{:css ".users"} {:tag :a :fn/text "invited-person-id"}]))
+      (btu/scroll-and-click [{:css ".users"} {:tag :a :fn/text "invited-person-id"}])
+      (btu/wait-page-loaded)
+      (is (btu/eventually-visible? {:tag :div :fn/has-text "Successfully joined workflow handling."}))
+      (is (btu/eventually-visible? [:workflow {:fn/has-text (btu/context-get :workflow-title)}]))
+      (is (= {"Organization" "The Default Organization"
+              "Title" (btu/context-get :workflow-title)
+              "Type" "Master workflow"
+              "Handlers" "Invited Person Name"
+              "Active" true
+              "Forms" ""}
+             (slurp-fields :workflow))))))
+
 (deftest test-approve-with-end-date
   (testing "submit test data with API"
     (btu/context-assoc! :form-id (test-helpers/create-form! {:form/fields [{:field/title {:en "description" :fi "kuvaus" :sv "rubrik"}