├── .gitignore ├── .travis.yml ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── README.md ├── access_context.go ├── doc.go ├── docaction.go ├── docevent.go ├── docstate.go ├── doctype.go ├── document.go ├── errors.go ├── flow_test.go ├── group.go ├── mailbox.go ├── message.go ├── node.go ├── nodetype.go ├── role.go ├── sql ├── setup_blob_dirs.sh ├── setup_db.sh ├── users_master.sql ├── wf_ac_group_hierarchy.sql ├── wf_ac_group_roles.sql ├── wf_ac_perms_v.sql ├── wf_access_contexts.sql ├── wf_database.sql ├── wf_docactions_master.sql ├── wf_docevent_application.sql ├── wf_docevents.sql ├── wf_docstate_transitions.sql ├── wf_docstates_master.sql ├── wf_doctypes_master.sql ├── wf_documents.sql ├── wf_group_users.sql ├── wf_groups_master.sql ├── wf_mailboxes.sql ├── wf_messages.sql ├── wf_role_docactions.sql ├── wf_roles_master.sql ├── wf_users_master.sql ├── wf_workflow_nodes.sql └── wf_workflows.sql ├── user.go └── workflow.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.lo 4 | *.slo 5 | *.so 6 | 7 | *.a 8 | *.la 9 | *.lai 10 | 11 | *.dylib 12 | 13 | *.exe 14 | *.dll 15 | 16 | # Directories 17 | _obj 18 | _test 19 | 20 | /bin/ 21 | /pkg/ 22 | /vendor/ 23 | 24 | # Architecture specific extensions/prefixes 25 | *.[568vqo] 26 | [568vq].out 27 | 28 | *.cgo*.go 29 | *.cgo*.c 30 | _cgo_defun.c 31 | _cgo_gotypes.go 32 | _cgo_export.* 33 | _cgo_* 34 | 35 | _testmain.go 36 | *.test 37 | *.prof 38 | 39 | # Backup files, etc. 40 | *~ 41 | .*.swp 42 | *.orig 43 | core 44 | 45 | # Take care of macOS weirdness; for potential contributors using it 46 | .DS_Store 47 | ._* 48 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.x 5 | 6 | services: 7 | - mysql 8 | 9 | before_script: 10 | - ./sql/setup_db.sh -t 11 | - ./sql/setup_blob_dirs.sh 12 | 13 | notifications: 14 | email: 15 | recipients: 16 | - js@ojuslabs.com 17 | on_success: change 18 | on_failure: always 19 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/go-sql-driver/mysql" 6 | packages = ["."] 7 | revision = "a0583e0143b1624142adab07e0e97fe106d99561" 8 | version = "v1.3" 9 | 10 | [solve-meta] 11 | analyzer-name = "dep" 12 | analyzer-version = 1 13 | inputs-digest = "205e5df3f3a88597f2cfa8152ff0a54a4e7210c1dbf10c6ecdfc00897ac48da9" 14 | solver-name = "gps-cdcl" 15 | solver-version = 1 16 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | 23 | 24 | [[constraint]] 25 | name = "github.com/go-sql-driver/mysql" 26 | version = "1.3.0" 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | 3 | Apache License 4 | Version 2.0, January 2004 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by 15 | the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity 26 | exercising permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but 34 | not limited to compiled object code, generated documentation, 35 | and conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or 38 | Object form, made available under the License, as indicated by a 39 | copyright notice that is included in or attached to the work 40 | (an example is provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the purposes 46 | of this License, Derivative Works shall not include works that remain 47 | separable from, or merely link (or bind by name) to the interfaces of, 48 | the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including 51 | the original version of the Work and any modifications or additions 52 | to that Work or Derivative Works thereof, that is intentionally 53 | submitted to Licensor for inclusion in the Work by the copyright owner 54 | or by an individual or Legal Entity authorized to submit on behalf of 55 | the copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent 57 | to the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control systems, 59 | and issue tracking systems that are managed by, or on behalf of, the 60 | Licensor for the purpose of discussing and improving the Work, but 61 | excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, 72 | publicly display, publicly perform, sublicense, and distribute the 73 | Work and such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | (except as stated in this section) patent license to make, have made, 79 | use, offer to sell, sell, import, and otherwise transfer the Work, 80 | where such license applies only to those patent claims licensable 81 | by such Contributor that are necessarily infringed by their 82 | Contribution(s) alone or by combination of their Contribution(s) 83 | with the Work to which such Contribution(s) was submitted. If You 84 | institute patent litigation against any entity (including a 85 | cross-claim or counterclaim in a lawsuit) alleging that the Work 86 | or a Contribution incorporated within the Work constitutes direct 87 | or contributory patent infringement, then any patent licenses 88 | granted to You under this License for that Work shall terminate 89 | as of the date such litigation is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the 92 | Work or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You 94 | meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or 97 | Derivative Works a copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works 103 | that You distribute, all copyright, patent, trademark, and 104 | attribution notices from the Source form of the Work, 105 | excluding those notices that do not pertain to any part of 106 | the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must 110 | include a readable copy of the attribution notices contained 111 | within such NOTICE file, excluding those notices that do not 112 | pertain to any part of the Derivative Works, in at least one 113 | of the following places: within a NOTICE text file distributed 114 | as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, 116 | within a display generated by the Derivative Works, if and 117 | wherever such third-party notices normally appear. The contents 118 | of the NOTICE file are for informational purposes only and 119 | do not modify the License. You may add Your own attribution 120 | notices within Derivative Works that You distribute, alongside 121 | or as an addendum to the NOTICE text from the Work, provided 122 | that such additional attribution notices cannot be construed 123 | as modifying the License. 124 | 125 | You may add Your own copyright statement to Your modifications and 126 | may provide additional or different license terms and conditions 127 | for use, reproduction, or distribution of Your modifications, or 128 | for any such Derivative Works as a whole, provided Your use, 129 | reproduction, and distribution of the Work otherwise complies with 130 | the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. Unless You explicitly state otherwise, 133 | any Contribution intentionally submitted for inclusion in the Work 134 | by You to the Licensor shall be under the terms and conditions of 135 | this License, without any additional terms or conditions. 136 | Notwithstanding the above, nothing herein shall supersede or modify 137 | the terms of any separate license agreement you may have executed 138 | with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. This License does not grant permission to use the trade 141 | names, trademarks, service marks, or product names of the Licensor, 142 | except as required for reasonable and customary use in describing the 143 | origin of the Work and reproducing the content of the NOTICE file. 144 | 145 | 7. Disclaimer of Warranty. Unless required by applicable law or 146 | agreed to in writing, Licensor provides the Work (and each 147 | Contributor provides its Contributions) on an "AS IS" BASIS, 148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 149 | implied, including, without limitation, any warranties or conditions 150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 151 | PARTICULAR PURPOSE. You are solely responsible for determining the 152 | appropriateness of using or redistributing the Work and assume any 153 | risks associated with Your exercise of permissions under this License. 154 | 155 | 8. Limitation of Liability. In no event and under no legal theory, 156 | whether in tort (including negligence), contract, or otherwise, 157 | unless required by applicable law (such as deliberate and grossly 158 | negligent acts) or agreed to in writing, shall any Contributor be 159 | liable to You for damages, including any direct, indirect, special, 160 | incidental, or consequential damages of any character arising as a 161 | result of this License or out of the use or inability to use the 162 | Work (including but not limited to damages for loss of goodwill, 163 | work stoppage, computer failure or malfunction, or any and all 164 | other commercial damages or losses), even if such Contributor 165 | has been advised of the possibility of such damages. 166 | 167 | 9. Accepting Warranty or Additional Liability. While redistributing 168 | the Work or Derivative Works thereof, You may choose to offer, 169 | and charge a fee for, acceptance of support, warranty, indemnity, 170 | or other liability obligations and/or rights consistent with this 171 | License. However, in accepting such obligations, You may act only 172 | on Your own behalf and on Your sole responsibility, not on behalf 173 | of any other Contributor, and only if You agree to indemnify, 174 | defend, and hold each Contributor harmless for any liability 175 | incurred by, or claims asserted against, such Contributor by reason 176 | of your accepting any such warranty or additional liability. 177 | 178 | END OF TERMS AND CONDITIONS 179 | 180 | APPENDIX: How to apply the Apache License to your work. 181 | 182 | To apply the Apache License to your work, attach the following 183 | boilerplate notice, with the fields enclosed by brackets "{}" 184 | replaced with your own identifying information. (Don't include 185 | the brackets!) The text should be enclosed in the appropriate 186 | comment syntax for the file format. We also recommend that a 187 | file or class name and description of purpose be included on the 188 | same "printed page" as the copyright notice for easier 189 | identification within third-party archives. 190 | 191 | Copyright {yyyy} {name of copyright owner} 192 | 193 | Licensed under the Apache License, Version 2.0 (the "License"); 194 | you may not use this file except in compliance with the License. 195 | You may obtain a copy of the License at 196 | 197 | http://www.apache.org/licenses/LICENSE-2.0 198 | 199 | Unless required by applicable law or agreed to in writing, software 200 | distributed under the License is distributed on an "AS IS" BASIS, 201 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 202 | See the License for the specific language governing permissions and 203 | limitations under the License. 204 | 205 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | [![Build Status](https://travis-ci.org/js-ojus/flow.svg?branch=master)](https://travis-ci.org/js-ojus/flow) 18 | [![Go Report Card](https://goreportcard.com/badge/github.com/js-ojus/flow)](https://goreportcard.com/report/github.com/js-ojus/flow) 19 | [![GoDoc](https://godoc.org/github.com/js-ojus/flow?status.svg)](https://godoc.org/github.com/js-ojus/flow) 20 | 21 | ## STATUS 22 | 23 | `flow` is usable in its current state, even though it hasn't acquired all the desired functionality. 24 | 25 | **N.B.** Those who intend to use `flow` should always use the most recent release. DO NOT use `master`! 26 | 27 | ## `flow` 28 | 29 | `flow` is a tiny open source (Apache 2-licensed) workflow engine written in Go (golang). 30 | 31 | ### What `flow` is 32 | 33 | As a workflow engine, `flow` intends to help in defining and driving "front office" <---> "back office" document flows. Examples of such flows are: 34 | 35 | - customer registration and verification of details, 36 | - assignment of work to field personnel, and its follow-up, 37 | - review and approval of documents, and 38 | - trouble ticket life cycle. 39 | 40 | `flow` provides value in scenarios of type "programming in the large", though it is not (currently) distributed in nature. As such, it addresses only orchestration, not choreography! 41 | 42 | `flow` - at least currently - aims to support only graph-like regimes (not hierarchical). 43 | 44 | ### What `flow` is not 45 | 46 | `flow` is a library, not a full-stack solution. Accordingly, it cannot be downloaded and deployed as a ready-to-use service. It has to be used by an application that programs workflow definitions and processing. The only "programming in the small" language supported is Go! Of course, you could employ `flow` in a microservice architecture by wrapping it in a thin service. That can enable you to use your favourite programming language to drive `flow`. 47 | 48 | ### Express non-goals 49 | 50 | `flow` is intended to be small! It is expressly **not** intended to be an enterprise-grade workflow engine. Accordingly, import from - and export to - workflow modelling formats like BPMN/XPDL are not supported. Similarly, executable specifications like BPEL and Wf-XML are not supported. True enterprise-grade engines already exist for addressing such complex workflows and interoperability as require high-end engines. 51 | 52 | ## `flow` Concepts 53 | 54 | Let us familiarise ourselves with the most important concepts and moving parts of `flow`. Even as you read the following, it is highly recommended that you read the database table definitions in `sql` directory, as well as the corresponding object definitions in their respective `*.go` files. That should help you in forming a mental model of `flow` faster. 55 | 56 | ### Users 57 | 58 | `flow` neither creates nor manages users. It assumes that an external identity provider is in charge of user management. Similarly, `flow` does not deal with user authentication, nor does it manage authorisation of tasks not flowing (pun intended) though it! 59 | 60 | Accordingly, users in `flow` are represented only by their unique IDs, names and unique e-mail addresses. The identity provider has to also provide the status of the user: active vs. inactive. 61 | 62 | ### Groups 63 | 64 | `flow`, similar to most UNIXes, implicitly creates a group for each defined user. This is a singleton group: its only member is the corresponding user. 65 | 66 | In addition, arbitrary general (non-singleton) groups can be defined by including one or more users as members. The relationship between users and groups is M:N. 67 | 68 | ### Roles 69 | 70 | Sets of document actions permitted for a given document type, can be grouped into roles. For instance, some users should be able to raise a request, but not approve it. Some others should be able to, in turn, approve or reject it. Roles make it convenient to group logically related sets of permissions. 71 | 72 | See `Document Types` and `Document Actions` for more details. 73 | 74 | ### Access Contexts 75 | 76 | An access context is a namespace that defines jurisdictions of permissions granted to users and groups. Examples of such jurisdictions include departments, branches, cost centres and projects. 77 | 78 | Within an access context, a given user (though the associated singleton group) or group can be assigned one or more roles. The effective set of permissions available to a user - in this access context - is the union of the permissions granted through all roles assigned to this user, through all groups the user is included in. 79 | 80 | ### Document Types 81 | 82 | Each type of document, whose workflow is managed by `flow`, has a unique `DocType` that is defined by the consuming application. Document types are one of the central concepts in `flow`. They serve as namespaces for several other types of entities. 83 | 84 | A document type is just a string. `flow` does not assume anything about the specifics of any document type. Nonetheless, it is highly recommended, but not necessary, that document types be defined in a system of hierarchical namespaces. For example: 85 | 86 | PUR:RFQ 87 | 88 | could mean that the department is 'Purchasing', while the document type is 'Request For Quotation'. As a variant, 89 | 90 | PUR:ORD 91 | 92 | could mean that the document type is 'Purchase Order'. 93 | 94 | ### Document States 95 | 96 | Every document has various stages in its life. The possible stages in the life of a document are encoded as a set of `DocState`s specific to its document type. 97 | 98 | A document state is just a string. `flow` does not assume anything about the specifics of any document state. 99 | 100 | ### Document Actions 101 | 102 | Given a document in a particular state, each legal (for that state) action on the document could result in a new state. `DocAction`s enumerate possible actions that affect documents. 103 | 104 | A document action is just a string. `flow` does not assume anything about the specifics of any document action. 105 | 106 | ### Documents 107 | 108 | A document comprises: 109 | 110 | - title, 111 | - body (usually, text), 112 | - enclosures/attachments (optional), 113 | - tags (optional) and 114 | - children documents (optional). 115 | 116 | Thus, `Document` is a recursive structure. As a document transitions from state to state, a child document is created and attached to it. Thus, documents form a hierarchy, much like an e-mail thread, a discussion forum thread or a trouble ticket thread. 117 | 118 | Only root documents have their own titles. Similarly, only root documents participate in workflows. Children documents follow their root documents along. 119 | 120 | ### Workflows 121 | 122 | Each `DocType` defined in `flow` should have an associated `Workflow` defined. This workflow handles the lifecycle of documents of that type. 123 | 124 | A workflow is begun when a document of a particular type is created. This workflow then tracks the document as it transitions from one document state to another, in reponse to either user actions or system events. These document states and their transitions constitute the defining graph of this workflow. 125 | 126 | ### Document Events 127 | 128 | A `DocEvent` represents a user (or system) `DocAction` on a particular document that is in a particular `DocState`. They record the agent casuing them (system/which user), as well as the time when the action was performed. 129 | 130 | ### Nodes 131 | 132 | A document in a particular state in its workflow, is sent to a `Node`. There, it awaits an action to take place that moves it along the workflow, to a different state. Thus, nodes are receivers of document events. 133 | 134 | A `DocAction` on a document in a `DocState` may require some processing to be performed before a possible state transition. Accordingly, applications can register custom processing functions with nodes. Such functions are invoked by the respective nodes when an action is received by them. These `NodeFunc`s generate the messages that are dispatched to applicable mailboxes. 135 | 136 | ### Mailboxes and Messages 137 | 138 | Every defined user has a `Mailbox` of virtually no size limit. Documents that transition into specific states trigger notifications for specific users. These notifications are delivered to the mailboxes of such users. 139 | 140 | The actual content of a notification constitutes a `Message`. The most essential details include the document reference, the `Node` in the workflow at which the document is, and the time at which the message was sent. 141 | 142 | Upon getting notified, users open the corresponding documents, and take appropriate actions. 143 | 144 | ## Example Structure 145 | 146 | The following is a simple example structure. 147 | 148 | ``` 149 | Document Type : docType1 150 | Document States : [ 151 | docState1, docState2, docState3, docState4 // for example 152 | ] 153 | Document Actions : [ 154 | docAction12, docAction23, docAction34 // for the above document states 155 | ] 156 | Document Type State Transitions : [ 157 | docState1 --docAction12--> docState2, 158 | docState2 --docAction23--> docState3, 159 | docState3 --docAction34--> docState4, 160 | ] 161 | 162 | Access Contexts : [ 163 | accCtx1, accCtx2 // for example 164 | ] 165 | 166 | Workflow : { 167 | Name : wFlow1, 168 | Initial State : docState1 169 | } 170 | Nodes : [ 171 | node1: { 172 | Document Type : docType1, 173 | Workflow : wFlow1, 174 | Node Type : NodeTypeBegin, // note this 175 | From State : docState1, 176 | Access Context : accCtx1, 177 | }, 178 | node2: { 179 | Document Type : docType1, 180 | Workflow : wFlow1, 181 | Node Type : NodeTypeLinear, // note this 182 | From State : docState2, 183 | Access Context : accCtx2, // a different context 184 | }, 185 | node3: { 186 | Document Type : docType1, 187 | Workflow : wFlow1, 188 | Node Type : NodeTypeEnd, // note this 189 | From State : docState3, 190 | Access Context : accCtx1, 191 | }, 192 | ] 193 | ``` 194 | 195 | With the above setup, we can dispatch document events to the workflow appropriately. With each event, the workflow moves along, as defined. 196 | -------------------------------------------------------------------------------- /access_context.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "math" 21 | "strings" 22 | ) 23 | 24 | // AccessContextID is the type unique access context identifiers. 25 | type AccessContextID int64 26 | 27 | // AccessContext is a namespace that provides an environment for 28 | // workflow execution. 29 | // 30 | // It is an environment in which users are mapped into a hierarchy 31 | // that determines certain aspects of workflow control. This 32 | // hierarchy, usually, but not necessarily, reflects an organogram. In 33 | // each access context, applicable groups are mapped to their 34 | // respective intended permissions. This mapping happens through 35 | // roles. 36 | // 37 | // Each workflow that operates on a document type is given an 38 | // associated access context. This context is used to determine 39 | // workflow routing, possible branching and rendezvous points. 40 | // 41 | // Please note that the same workflow may operate independently in 42 | // multiple unrelated access contexts. Thus, a workflow is not 43 | // limited to one access context. Conversely, an access context can 44 | // have several workflows operating on it, for various document types. 45 | // Therefore, the relationship between workflows and access contexts 46 | // is M:N. 47 | // 48 | // For complex organisational requirements, the name of the access 49 | // context can be made hierarchical with a suitable delimiter. For 50 | // example: 51 | // 52 | // - IN:south:HYD:BR-101 53 | // - sbu-08/client-0249/prj-006348 54 | type AccessContext struct { 55 | ID AccessContextID `json:"ID"` // Unique identifier of this access context 56 | Name string `json:"Name,omitempty"` // Globally-unique namespace; can be a department, project, location, branch, etc. 57 | Active bool `json:"Active,omitempty"` // Can a workflow be initiated in this context? 58 | } 59 | 60 | // AcGroupRoles holds the information of the various roles that each 61 | // group has been assigned in this access context. 62 | type AcGroupRoles struct { 63 | Group string `json:"Group"` // Group whose roles this represents 64 | Roles []Role `json:"Roles"` // Map holds the role assignments to groups 65 | } 66 | 67 | // AcGroup holds the information of a user together with the user's 68 | // reporting authority. 69 | type AcGroup struct { 70 | Group `json:"Group"` // An assigned user 71 | ReportsTo Group `json:"ReportsTo"` // Reporting authority of this user 72 | } 73 | 74 | // Unexported type, only for convenience methods. 75 | type _AccessContexts struct{} 76 | 77 | // AccessContexts provides a resource-like interface to access 78 | // contexts in the system. 79 | var AccessContexts _AccessContexts 80 | 81 | // New creates a new access context with the globally-unique name 82 | // given. 83 | func (_AccessContexts) New(otx *sql.Tx, name string) (AccessContextID, error) { 84 | name = strings.TrimSpace(name) 85 | if name == "" { 86 | return 0, errors.New("access context name should be non-empty") 87 | } 88 | 89 | var tx *sql.Tx 90 | var err error 91 | if otx == nil { 92 | tx, err = db.Begin() 93 | if err != nil { 94 | return 0, err 95 | } 96 | defer tx.Rollback() 97 | } else { 98 | tx = otx 99 | } 100 | 101 | q := `INSERT INTO wf_access_contexts(name, active) VALUES(?, 1)` 102 | res, err := tx.Exec(q, name) 103 | if err != nil { 104 | return 0, err 105 | } 106 | acID, err := res.LastInsertId() 107 | if err != nil { 108 | return 0, err 109 | } 110 | 111 | if otx == nil { 112 | err := tx.Commit() 113 | if err != nil { 114 | return 0, err 115 | } 116 | } 117 | 118 | return AccessContextID(acID), nil 119 | } 120 | 121 | // List answers a list of access contexts defined in the system. 122 | // 123 | // Result set begins with ID >= `offset`, and has not more than 124 | // `limit` elements. A value of `0` for `offset` fetches from the 125 | // beginning, while a value of `0` for `limit` fetches until the end. 126 | func (_AccessContexts) List(prefix string, offset, limit int64) ([]*AccessContext, error) { 127 | if offset < 0 || limit < 0 { 128 | return nil, errors.New("offset and limit should be non-negative integers") 129 | } 130 | if limit == 0 { 131 | limit = math.MaxInt64 132 | } 133 | 134 | var q string 135 | var rows *sql.Rows 136 | var err error 137 | 138 | prefix = strings.TrimSpace(prefix) 139 | if prefix == "" { 140 | q = ` 141 | SELECT id, name, active 142 | FROM wf_access_contexts 143 | ORDER BY id 144 | LIMIT ? OFFSET ? 145 | ` 146 | rows, err = db.Query(q, limit, offset) 147 | } else { 148 | q = ` 149 | SELECT id, name, active 150 | FROM wf_access_contexts 151 | WHERE name LIKE ? 152 | ORDER BY id 153 | LIMIT ? OFFSET ? 154 | ` 155 | rows, err = db.Query(q, prefix+"%", limit, offset) 156 | } 157 | 158 | if err != nil { 159 | return nil, err 160 | } 161 | defer rows.Close() 162 | 163 | ary := make([]*AccessContext, 0, 10) 164 | for rows.Next() { 165 | var elem AccessContext 166 | err = rows.Scan(&elem.ID, &elem.Name, &elem.Active) 167 | if err != nil { 168 | return nil, err 169 | } 170 | ary = append(ary, &elem) 171 | } 172 | if err = rows.Err(); err != nil { 173 | return nil, err 174 | } 175 | 176 | return ary, nil 177 | } 178 | 179 | // ListByGroup answers a list of access contexts in which the given 180 | // group is included. 181 | // 182 | // Result set begins with ID >= `offset`, and has not more than 183 | // `limit` elements. A value of `0` for `offset` fetches from the 184 | // beginning, while a value of `0` for `limit` fetches until the end. 185 | func (_AccessContexts) ListByGroup(gid GroupID, offset, limit int64) ([]*AccessContext, error) { 186 | if offset < 0 || limit < 0 { 187 | return nil, errors.New("offset and limit should be non-negative integers") 188 | } 189 | if limit == 0 { 190 | limit = math.MaxInt64 191 | } 192 | 193 | q := ` 194 | SELECT ac.id, ac.name, ac.active 195 | FROM wf_access_contexts ac 196 | JOIN wf_ac_group_hierarchy agh ON agh.ac_id = ac.id 197 | WHERE agh.group_id = ? 198 | ORDER BY agh.ac_id 199 | LIMIT ? OFFSET ? 200 | ` 201 | rows, err := db.Query(q, gid, limit, offset) 202 | if err != nil { 203 | return nil, err 204 | } 205 | defer rows.Close() 206 | 207 | ary := make([]*AccessContext, 0, 10) 208 | for rows.Next() { 209 | var elem AccessContext 210 | err = rows.Scan(&elem.ID, &elem.Name, &elem.Active) 211 | if err != nil { 212 | return nil, err 213 | } 214 | ary = append(ary, &elem) 215 | } 216 | if err = rows.Err(); err != nil { 217 | return nil, err 218 | } 219 | 220 | return ary, nil 221 | } 222 | 223 | // ListByUser answers a list of access contexts in which the given 224 | // group is included. 225 | // 226 | // Result set begins with ID >= `offset`, and has not more than 227 | // `limit` elements. A value of `0` for `offset` fetches from the 228 | // beginning, while a value of `0` for `limit` fetches until the end. 229 | func (_AccessContexts) ListByUser(uid UserID, offset, limit int64) ([]*AccessContext, error) { 230 | if offset < 0 || limit < 0 { 231 | return nil, errors.New("offset and limit should be non-negative integers") 232 | } 233 | if limit == 0 { 234 | limit = math.MaxInt64 235 | } 236 | 237 | q := ` 238 | SELECT ac.id, ac.name, ac.active 239 | FROM wf_access_contexts ac 240 | JOIN wf_ac_group_hierarchy agh ON agh.ac_id = ac.id 241 | WHERE agh.group_id = ( 242 | SELECT gm.id 243 | FROM wf_groups_master gm 244 | JOIN wf_group_users gu ON gu.group_id = gm.id 245 | WHERE gu.user_id = ? 246 | AND gm.group_type = 'S' 247 | ) 248 | ORDER BY agh.ac_id 249 | LIMIT ? OFFSET ? 250 | ` 251 | rows, err := db.Query(q, uid, limit, offset) 252 | if err != nil { 253 | return nil, err 254 | } 255 | defer rows.Close() 256 | 257 | ary := make([]*AccessContext, 0, 10) 258 | for rows.Next() { 259 | var elem AccessContext 260 | err = rows.Scan(&elem.ID, &elem.Name, &elem.Active) 261 | if err != nil { 262 | return nil, err 263 | } 264 | ary = append(ary, &elem) 265 | } 266 | if err = rows.Err(); err != nil { 267 | return nil, err 268 | } 269 | 270 | return ary, nil 271 | } 272 | 273 | // Get fetches the requested access context that determines how the 274 | // workflows that operate in its context run. 275 | func (_AccessContexts) Get(id AccessContextID) (*AccessContext, error) { 276 | q := ` 277 | SELECT id, name, active 278 | FROM wf_access_contexts 279 | WHERE id = ? 280 | ` 281 | res := db.QueryRow(q, id) 282 | var elem AccessContext 283 | err := res.Scan(&elem.ID, &elem.Name, &elem.Active) 284 | if err != nil { 285 | return nil, err 286 | } 287 | 288 | return &elem, nil 289 | } 290 | 291 | // Rename changes the name of the given access context to the 292 | // specified new name. 293 | func (_AccessContexts) Rename(otx *sql.Tx, id AccessContextID, name string) error { 294 | name = strings.TrimSpace(name) 295 | if name == "" { 296 | return errors.New("access context name should be non-empty") 297 | } 298 | 299 | var tx *sql.Tx 300 | var err error 301 | if otx == nil { 302 | tx, err = db.Begin() 303 | if err != nil { 304 | return err 305 | } 306 | defer tx.Rollback() 307 | } else { 308 | tx = otx 309 | } 310 | 311 | q := ` 312 | UPDATE wf_access_contexts 313 | SET name = ? 314 | WHERE id = ? 315 | ` 316 | _, err = tx.Exec(q, name, id) 317 | if err != nil { 318 | return err 319 | } 320 | 321 | if otx == nil { 322 | err := tx.Commit() 323 | if err != nil { 324 | return err 325 | } 326 | } 327 | 328 | return nil 329 | } 330 | 331 | // SetActive updates the given access context with the new active 332 | // status. 333 | func (_AccessContexts) SetActive(otx *sql.Tx, id AccessContextID, active bool) error { 334 | act := 0 335 | if active { 336 | act = 1 337 | } 338 | 339 | var tx *sql.Tx 340 | var err error 341 | if otx == nil { 342 | tx, err = db.Begin() 343 | if err != nil { 344 | return err 345 | } 346 | defer tx.Rollback() 347 | } else { 348 | tx = otx 349 | } 350 | 351 | q := ` 352 | UPDATE wf_access_contexts 353 | SET active = ? 354 | WHERE id = ? 355 | ` 356 | _, err = tx.Exec(q, act, id) 357 | if err != nil { 358 | return err 359 | } 360 | 361 | if otx == nil { 362 | err := tx.Commit() 363 | if err != nil { 364 | return err 365 | } 366 | } 367 | 368 | return nil 369 | } 370 | 371 | // GroupRoles retrieves the groups --> roles mapping for this access 372 | // context. 373 | func (_AccessContexts) GroupRoles(id AccessContextID, gids []GroupID, offset, limit int64) (map[GroupID]*AcGroupRoles, error) { 374 | if id <= 0 { 375 | return nil, errors.New("access context ID should be a positive integer") 376 | } 377 | if len(gids) == 0 { 378 | return nil, errors.New("list of group IDs should be non-empty") 379 | } 380 | if offset < 0 || limit < 0 { 381 | return nil, errors.New("offset and limit should be non-negative integers") 382 | } 383 | if limit == 0 { 384 | limit = math.MaxInt64 385 | } 386 | 387 | args := make([]interface{}, 0, len(gids)) 388 | args = append(args, id) 389 | for _, gid := range gids { 390 | args = append(args, gid) 391 | } 392 | args = append(args, limit) 393 | args = append(args, offset) 394 | 395 | q := ` 396 | SELECT agrs.group_id, gm.name, agrs.role_id, rm.name 397 | FROM wf_ac_group_roles agrs 398 | JOIN wf_groups_master gm ON gm.id = agrs.group_id 399 | JOIN wf_roles_master rm ON rm.id = agrs.role_id 400 | WHERE agrs.ac_id = ? 401 | AND agrs.group_id IN (?` + strings.Repeat(",?", len(gids)-1) + `) 402 | ORDER BY agrs.group_id 403 | LIMIT ? OFFSET ? 404 | ` 405 | stmt, err := db.Prepare(q) 406 | if err != nil { 407 | return nil, err 408 | } 409 | rows, err := stmt.Query(args...) 410 | if err != nil { 411 | return nil, err 412 | } 413 | defer rows.Close() 414 | 415 | grs := make(map[GroupID]*AcGroupRoles) 416 | var curGid int64 417 | for rows.Next() { 418 | var gid int64 419 | var gname string 420 | var role Role 421 | err = rows.Scan(&gid, &gname, &role.ID, &role.Name) 422 | if err != nil { 423 | return nil, err 424 | } 425 | 426 | var gr *AcGroupRoles 427 | if curGid == gid { 428 | gr = grs[GroupID(gid)] 429 | } else { 430 | gr = &AcGroupRoles{Group: gname, Roles: make([]Role, 0, 4)} 431 | curGid = gid 432 | } 433 | gr.Roles = append(gr.Roles, role) 434 | grs[GroupID(gid)] = gr 435 | } 436 | if rows.Err() != nil { 437 | return nil, err 438 | } 439 | 440 | return grs, nil 441 | } 442 | 443 | // AddGroupRole assigns the specified role to the given group, if it 444 | // is not already assigned. 445 | func (_AccessContexts) AddGroupRole(otx *sql.Tx, id AccessContextID, gid GroupID, rid RoleID) error { 446 | if gid <= 0 || rid <= 0 { 447 | return errors.New("group ID and role ID should be positive integers") 448 | } 449 | 450 | var tx *sql.Tx 451 | var err error 452 | if otx == nil { 453 | tx, err = db.Begin() 454 | if err != nil { 455 | return err 456 | } 457 | defer tx.Rollback() 458 | } else { 459 | tx = otx 460 | } 461 | 462 | _, err = tx.Exec(`INSERT INTO wf_ac_group_roles(ac_id, group_id, role_id) VALUES(?, ?, ?)`, id, gid, rid) 463 | if err != nil { 464 | return err 465 | } 466 | 467 | if otx == nil { 468 | err := tx.Commit() 469 | if err != nil { 470 | return err 471 | } 472 | } 473 | 474 | return nil 475 | } 476 | 477 | // RemoveGroupRole unassigns the specified role from the given group. 478 | func (_AccessContexts) RemoveGroupRole(otx *sql.Tx, id AccessContextID, gid GroupID, rid RoleID) error { 479 | if gid <= 0 || rid <= 0 { 480 | return errors.New("group ID and role ID should be positive integers") 481 | } 482 | 483 | var tx *sql.Tx 484 | var err error 485 | if otx == nil { 486 | tx, err = db.Begin() 487 | if err != nil { 488 | return err 489 | } 490 | defer tx.Rollback() 491 | } else { 492 | tx = otx 493 | } 494 | 495 | _, err = tx.Exec(`DELETE FROM wf_ac_group_roles WHERE ac_id = ? AND group_id = ? AND role_id = ?`, id, gid, rid) 496 | if err != nil { 497 | return err 498 | } 499 | 500 | if otx == nil { 501 | err := tx.Commit() 502 | if err != nil { 503 | return err 504 | } 505 | } 506 | 507 | return nil 508 | } 509 | 510 | // Groups retrieves the users included in this access context. 511 | func (_AccessContexts) Groups(id AccessContextID, offset, limit int64) (map[GroupID]*AcGroup, error) { 512 | if offset < 0 || limit < 0 { 513 | return nil, errors.New("offset and limit should be non-negative integers") 514 | } 515 | if limit == 0 { 516 | limit = math.MaxInt64 517 | } 518 | 519 | q := ` 520 | SELECT gm.id, gm.name, gm.group_type, rep_to.id, rep_to.name, rep_to.group_type 521 | FROM wf_groups_master gm 522 | JOIN wf_ac_group_hierarchy auh ON auh.group_id = gm.id 523 | JOIN wf_groups_master rep_to ON rep_to.id = auh.reports_to 524 | WHERE auh.ac_id = ? 525 | ORDER BY auh.group_id 526 | LIMIT ? OFFSET ? 527 | ` 528 | rows, err := db.Query(q, id, limit, offset) 529 | if err != nil { 530 | return nil, err 531 | } 532 | defer rows.Close() 533 | 534 | gh := make(map[GroupID]*AcGroup) 535 | for rows.Next() { 536 | var g AcGroup 537 | err = rows.Scan(&g.ID, &g.Name, &g.GroupType, &g.ReportsTo.ID, &g.ReportsTo.Name, &g.ReportsTo.GroupType) 538 | if err != nil { 539 | return nil, err 540 | } 541 | 542 | gh[GroupID(g.ID)] = &g 543 | } 544 | if rows.Err() != nil { 545 | return nil, err 546 | } 547 | 548 | return gh, nil 549 | } 550 | 551 | // AddGroup adds the given group to this access context, with the 552 | // specified reporting authority within the hierarchy of this access 553 | // context. 554 | func (_AccessContexts) AddGroup(otx *sql.Tx, id AccessContextID, gid, reportsTo GroupID) error { 555 | if gid <= 0 || reportsTo < 0 { 556 | return errors.New("group ID should be a positive integer; reporting authority ID should be a non-negative integer") 557 | } 558 | 559 | var tx *sql.Tx 560 | var err error 561 | if otx == nil { 562 | tx, err = db.Begin() 563 | if err != nil { 564 | return err 565 | } 566 | defer tx.Rollback() 567 | } else { 568 | tx = otx 569 | } 570 | 571 | q := `INSERT INTO wf_ac_group_hierarchy(ac_id, group_id, reports_to) VALUES (?, ?, ?)` 572 | _, err = tx.Exec(q, id, gid, reportsTo) 573 | if err != nil { 574 | return err 575 | } 576 | 577 | if otx == nil { 578 | err := tx.Commit() 579 | if err != nil { 580 | return err 581 | } 582 | } 583 | 584 | return nil 585 | } 586 | 587 | // DeleteGroup removes the given group from this access context. 588 | func (_AccessContexts) DeleteGroup(otx *sql.Tx, id AccessContextID, gid GroupID) error { 589 | if gid <= 0 { 590 | return errors.New("user ID should be positive integer") 591 | } 592 | 593 | var tx *sql.Tx 594 | var err error 595 | if otx == nil { 596 | tx, err = db.Begin() 597 | if err != nil { 598 | return err 599 | } 600 | defer tx.Rollback() 601 | } else { 602 | tx = otx 603 | } 604 | 605 | q := `DELETE FROM wf_ac_group_hierarchy WHERE ac_id = ? AND group_id = ?` 606 | _, err = tx.Exec(q, id, gid) 607 | if err != nil { 608 | return err 609 | } 610 | 611 | if otx == nil { 612 | err := tx.Commit() 613 | if err != nil { 614 | return err 615 | } 616 | } 617 | 618 | return nil 619 | } 620 | 621 | // GroupReportsTo answers the group to whom the given group reports to, 622 | // within this access context. 623 | func (_AccessContexts) GroupReportsTo(id AccessContextID, uid GroupID) (GroupID, error) { 624 | q := ` 625 | SELECT reports_to 626 | FROM wf_ac_group_hierarchy 627 | WHERE ac_id = ? 628 | AND group_id = ? 629 | ` 630 | row := db.QueryRow(q, id, uid) 631 | var repID int64 632 | err := row.Scan(&repID) 633 | if err != nil { 634 | return 0, err 635 | } 636 | 637 | return GroupID(repID), nil 638 | } 639 | 640 | // GroupReportees answers a list of the groups who report to the given 641 | // group, within this access context. 642 | func (_AccessContexts) GroupReportees(id AccessContextID, uid GroupID) ([]GroupID, error) { 643 | q := ` 644 | SELECT group_id 645 | FROM wf_ac_group_hierarchy 646 | WHERE ac_id = ? 647 | AND reports_to = ? 648 | ` 649 | rows, err := db.Query(q, id, uid) 650 | if err != nil { 651 | return nil, err 652 | } 653 | defer rows.Close() 654 | 655 | ary := make([]GroupID, 0, 4) 656 | for rows.Next() { 657 | var repID int64 658 | err = rows.Scan(&repID) 659 | if err != nil { 660 | return nil, err 661 | } 662 | ary = append(ary, GroupID(repID)) 663 | } 664 | if err = rows.Err(); err != nil { 665 | return nil, err 666 | } 667 | 668 | return ary, nil 669 | } 670 | 671 | // ChangeReporting reassigns the group to a different reporting 672 | // authority. 673 | func (_AccessContexts) ChangeReporting(otx *sql.Tx, id AccessContextID, gid, reportsTo GroupID) error { 674 | if gid <= 0 || reportsTo < 0 { 675 | return errors.New("group ID should be positive integer; reporting authority ID should be a non-negative integer") 676 | } 677 | 678 | var tx *sql.Tx 679 | var err error 680 | if otx == nil { 681 | tx, err = db.Begin() 682 | if err != nil { 683 | return err 684 | } 685 | defer tx.Rollback() 686 | } else { 687 | tx = otx 688 | } 689 | 690 | q := ` 691 | UPDATE wf_ac_group_hierarchy 692 | SET reports_to = ? 693 | WHERE ac_id = ? 694 | AND group_id = ? 695 | ` 696 | _, err = tx.Exec(q, reportsTo, id, gid) 697 | if err != nil { 698 | return err 699 | } 700 | 701 | if otx == nil { 702 | err := tx.Commit() 703 | if err != nil { 704 | return err 705 | } 706 | } 707 | 708 | return nil 709 | } 710 | 711 | // IncludesGroup answers `true` if the given group is included in this 712 | // access context. 713 | func (_AccessContexts) IncludesGroup(id AccessContextID, gid GroupID) (bool, error) { 714 | if gid <= 0 { 715 | return false, errors.New("group ID should be a positive integer") 716 | } 717 | 718 | q := ` 719 | SELECT reports_to 720 | FROM wf_ac_group_hierarchy 721 | WHERE ac_id = ? 722 | AND group_id = ? 723 | ` 724 | var repTo int64 725 | row := db.QueryRow(q, id, gid) 726 | err := row.Scan(&repTo) 727 | if err != nil { 728 | if err == sql.ErrNoRows { 729 | return false, nil 730 | } 731 | return false, err 732 | } 733 | 734 | return true, nil 735 | } 736 | 737 | // IncludesUser answers `true` if the given user is included in this 738 | // access context. 739 | func (_AccessContexts) IncludesUser(id AccessContextID, uid UserID) (bool, error) { 740 | if uid <= 0 { 741 | return false, errors.New("user ID should be a positive integer") 742 | } 743 | 744 | q := ` 745 | SELECT COUNT(agh.reports_to) 746 | FROM wf_ac_group_hierarchy agh 747 | WHERE agh.ac_id = ? 748 | AND agh.group_id IN ( 749 | SELECT gm.id 750 | FROM wf_groups_master gm 751 | JOIN wf_group_users gu ON gu.group_id = gm.id 752 | WHERE gu.user_id = ? 753 | ) 754 | ` 755 | var count int64 756 | row := db.QueryRow(q, id, uid) 757 | err := row.Scan(&count) 758 | if err != nil { 759 | return false, err 760 | } 761 | 762 | if count == 0 { 763 | return false, nil 764 | } 765 | 766 | return true, nil 767 | } 768 | 769 | // UserPermissions answers a list of the permissions available to the 770 | // given user in this access context. 771 | func (_AccessContexts) UserPermissions(id AccessContextID, uid UserID) (map[DocTypeID][]DocAction, error) { 772 | if uid <= 0 { 773 | return nil, errors.New("user ID should be a positive integer") 774 | } 775 | 776 | q := ` 777 | SELECT acpv.doctype_id, acpv.docaction_id, dam.name, dam.reconfirm 778 | FROM wf_ac_perms_v acpv 779 | JOIN wf_docactions_master dam ON dam.id = acpv.docaction_id 780 | WHERE acpv.ac_id = ? 781 | AND acpv.user_id = ? 782 | ` 783 | rows, err := db.Query(q, id, uid) 784 | if err != nil { 785 | return nil, err 786 | } 787 | defer rows.Close() 788 | 789 | res := map[DocTypeID][]DocAction{} 790 | for rows.Next() { 791 | var dtid int64 792 | var da DocAction 793 | err = rows.Scan(&dtid, &da.ID, &da.Name, &da.Reconfirm) 794 | if err != nil { 795 | return nil, err 796 | } 797 | 798 | ary, ok := res[DocTypeID(dtid)] 799 | if !ok { 800 | ary = []DocAction{} 801 | } 802 | ary = append(ary, da) 803 | res[DocTypeID(dtid)] = ary 804 | } 805 | if rows.Err() != nil { 806 | return nil, err 807 | } 808 | 809 | return res, nil 810 | } 811 | 812 | // UserPermissionsByDocType answers a list of the permissions 813 | // available on the given document type, to the given user, in this 814 | // access context. 815 | func (_AccessContexts) UserPermissionsByDocType(id AccessContextID, dtype DocTypeID, uid UserID) ([]DocAction, error) { 816 | if id <= 0 || dtype <= 0 || uid <= 0 { 817 | return nil, errors.New("all identifiers should be positive integers") 818 | } 819 | 820 | q := ` 821 | SELECT acpv.docaction_id, dam.name, dam.reconfirm 822 | FROM wf_ac_perms_v acpv 823 | JOIN wf_docactions_master dam ON dam.id = acpv.docaction_id 824 | WHERE acpv.ac_id = ? 825 | AND acpv.doctype_id = ? 826 | AND acpv.user_id = ? 827 | ` 828 | rows, err := db.Query(q, id, dtype, uid) 829 | if err != nil { 830 | return nil, err 831 | } 832 | defer rows.Close() 833 | 834 | res := []DocAction{} 835 | for rows.Next() { 836 | var da DocAction 837 | err = rows.Scan(&da.ID, &da.Name, &da.Reconfirm) 838 | if err != nil { 839 | return nil, err 840 | } 841 | 842 | res = append(res, da) 843 | } 844 | if rows.Err() != nil { 845 | return nil, err 846 | } 847 | 848 | return res, nil 849 | } 850 | 851 | // GroupPermissions answers a list of the permissions available to the 852 | // given user in this access context. 853 | func (_AccessContexts) GroupPermissions(id AccessContextID, gid GroupID) (map[DocTypeID][]DocAction, error) { 854 | if gid <= 0 { 855 | return nil, errors.New("group ID should be a positive integer") 856 | } 857 | 858 | q := ` 859 | SELECT acpv.doctype_id, acpv.docaction_id, dam.name, dam.reconfirm 860 | FROM wf_ac_perms_v acpv 861 | JOIN wf_docactions_master dam ON dam.id = acpv.docaction_id 862 | WHERE acpv.ac_id = ? 863 | AND acpv.group_id = ? 864 | ` 865 | rows, err := db.Query(q, id, gid) 866 | if err != nil { 867 | return nil, err 868 | } 869 | defer rows.Close() 870 | 871 | res := map[DocTypeID][]DocAction{} 872 | for rows.Next() { 873 | var dtid int64 874 | var da DocAction 875 | err = rows.Scan(&dtid, &da.ID, &da.Name, &da.Reconfirm) 876 | if err != nil { 877 | return nil, err 878 | } 879 | 880 | ary, ok := res[DocTypeID(dtid)] 881 | if !ok { 882 | ary = []DocAction{} 883 | } 884 | ary = append(ary, da) 885 | res[DocTypeID(dtid)] = ary 886 | } 887 | if rows.Err() != nil { 888 | return nil, err 889 | } 890 | 891 | return res, nil 892 | } 893 | 894 | // GroupPermissionsByDocType answers a list of the permissions 895 | // available on the given document type, to the given user, in this 896 | // access context. 897 | func (_AccessContexts) GroupPermissionsByDocType(id AccessContextID, dtype DocTypeID, gid GroupID) ([]DocAction, error) { 898 | if id <= 0 || dtype <= 0 || gid <= 0 { 899 | return nil, errors.New("all identifiers should be positive integers") 900 | } 901 | 902 | q := ` 903 | SELECT acpv.docaction_id, dam.name, dam.reconfirm 904 | FROM wf_ac_perms_v acpv 905 | JOIN wf_docactions_master dam ON dam.id = acpv.docaction_id 906 | WHERE acpv.ac_id = ? 907 | AND acpv.doctype_id = ? 908 | AND acpv.group_id = ? 909 | ` 910 | rows, err := db.Query(q, id, dtype, gid) 911 | if err != nil { 912 | return nil, err 913 | } 914 | defer rows.Close() 915 | 916 | res := []DocAction{} 917 | for rows.Next() { 918 | var da DocAction 919 | err = rows.Scan(&da.ID, &da.Name, &da.Reconfirm) 920 | if err != nil { 921 | return nil, err 922 | } 923 | 924 | res = append(res, da) 925 | } 926 | if rows.Err() != nil { 927 | return nil, err 928 | } 929 | 930 | return res, nil 931 | } 932 | 933 | // UserHasPermission answers `true` if the given user has the 934 | // requested action enabled on the specified document type; `false` 935 | // otherwise. 936 | func (_AccessContexts) UserHasPermission(id AccessContextID, uid UserID, dtype DocTypeID, action DocActionID) (bool, error) { 937 | if uid <= 0 || dtype <= 0 || action <= 0 { 938 | return false, errors.New("invalid user ID or document type or document action") 939 | } 940 | 941 | q := ` 942 | SELECT role_id FROM wf_ac_perms_v 943 | WHERE ac_id = ? 944 | AND user_id = ? 945 | AND doctype_id = ? 946 | AND docaction_id = ? 947 | LIMIT 1 948 | ` 949 | row := db.QueryRow(q, id, uid, dtype, action) 950 | var roleID int64 951 | err := row.Scan(&roleID) 952 | if err != nil { 953 | if err == sql.ErrNoRows { 954 | return false, nil 955 | } 956 | return false, err 957 | } 958 | return true, nil 959 | } 960 | 961 | // GroupHasPermission answers `true` if the given group has the 962 | // requested action enabled on the specified document type; `false` 963 | // otherwise. 964 | func (ac *AccessContext) GroupHasPermission(id AccessContextID, gid GroupID, dtype DocTypeID, action DocActionID) (bool, error) { 965 | if gid <= 0 || dtype <= 0 || action <= 0 { 966 | return false, errors.New("invalid group ID or document type or document action") 967 | } 968 | 969 | q := ` 970 | SELECT role_id FROM wf_ac_perms_v 971 | WHERE ac_id = ? 972 | AND group_id = ? 973 | AND doctype_id = ? 974 | AND docaction_id = ? 975 | LIMIT 1 976 | ` 977 | row := db.QueryRow(q, id, gid, dtype, action) 978 | var roleID int64 979 | err := row.Scan(&roleID) 980 | if err != nil { 981 | if err == sql.ErrNoRows { 982 | return false, nil 983 | } 984 | return false, err 985 | } 986 | return true, nil 987 | } 988 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package flow is a tiny workflow engine written in Go (golang). 16 | package flow 17 | 18 | import ( 19 | "database/sql" 20 | "log" 21 | ) 22 | 23 | const ( 24 | // DefACRoleCount is the default number of roles a group can have 25 | // in an access context. 26 | DefACRoleCount = 1 27 | ) 28 | 29 | var db *sql.DB 30 | var blobsDir string 31 | 32 | // 33 | 34 | func init() { 35 | f := log.Flags() 36 | log.SetFlags(f | log.Lmicroseconds | log.Lshortfile) 37 | } 38 | 39 | // RegisterDB provides an already initialised database handle to `flow`. 40 | // 41 | // N.B. This method **MUST** be called before anything else in `flow`. 42 | func RegisterDB(sdb *sql.DB) error { 43 | if sdb == nil { 44 | log.Fatal("given database handle is `nil`") 45 | } 46 | db = sdb 47 | 48 | return nil 49 | } 50 | 51 | // SetBlobsDir specifies the base directory inside which blob files 52 | // should be stored. 53 | // 54 | // Inside this base directory, 256 subdirectories are created as hex 55 | // `00` through `ff`. A blob is stored in the subdirectory whose name 56 | // matches the first two hex digits of its SHA1 sum. 57 | // 58 | // N.B. Once set, this MUST NOT change between runs. Doing so will 59 | // result in loss of all previously stored blobs. In addition, 60 | // corresponding documents get corrupted. 61 | func SetBlobsDir(base string) error { 62 | if base == "" { 63 | log.Fatal("given base directory path is empty") 64 | } 65 | blobsDir = base 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /docaction.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "math" 21 | "strings" 22 | ) 23 | 24 | // DocActionID is the type of unique identifiers of document actions. 25 | type DocActionID int64 26 | 27 | // DocAction enumerates the types of actions in the system, as defined 28 | // by the consuming application. Each document action has an 29 | // associated workflow transition that it causes. 30 | // 31 | // Accordingly, `flow` does not assume anything about the specifics of 32 | // the any document action. Instead, it treats document actions as plain, 33 | // but controlled, vocabulary. Good examples include: 34 | // 35 | // CREATE, 36 | // REVIEW, 37 | // APPROVE, 38 | // REJECT, 39 | // COMMENT, 40 | // CLOSE, and 41 | // REOPEN. 42 | // 43 | // N.B. All document actions must be defined as constant strings. 44 | type DocAction struct { 45 | ID DocActionID `json:"ID"` // Unique identifier of this action 46 | Name string `json:"Name"` // Globally-unique name of this action 47 | Reconfirm bool `json:"Reconfirm"` // Should the user be prompted for a reconfirmation of this action? 48 | } 49 | 50 | // Unexported type, only for convenience methods. 51 | type _DocActions struct{} 52 | 53 | // DocActions provides a resource-like interface to document actions 54 | // in the system. 55 | var DocActions _DocActions 56 | 57 | // New creates and registers a new document action in the system. 58 | func (_DocActions) New(otx *sql.Tx, name string, reconfirm bool) (DocActionID, error) { 59 | name = strings.TrimSpace(name) 60 | if name == "" { 61 | return 0, errors.New("document action cannot be empty") 62 | } 63 | 64 | var tx *sql.Tx 65 | var err error 66 | if otx == nil { 67 | tx, err = db.Begin() 68 | if err != nil { 69 | return 0, err 70 | } 71 | defer tx.Rollback() 72 | } else { 73 | tx = otx 74 | } 75 | 76 | var res sql.Result 77 | if reconfirm { 78 | res, err = tx.Exec("INSERT INTO wf_docactions_master(name, reconfirm) VALUES(?, ?)", name, 1) 79 | } else { 80 | res, err = tx.Exec("INSERT INTO wf_docactions_master(name, reconfirm) VALUES(?, ?)", name, 0) 81 | } 82 | if err != nil { 83 | return 0, err 84 | } 85 | var aid int64 86 | aid, err = res.LastInsertId() 87 | if err != nil { 88 | return 0, err 89 | } 90 | 91 | if otx == nil { 92 | err = tx.Commit() 93 | if err != nil { 94 | return 0, err 95 | } 96 | } 97 | 98 | return DocActionID(aid), nil 99 | } 100 | 101 | // List answers a subset of the document actions, based on the input 102 | // specification. 103 | // 104 | // Result set begins with ID >= `offset`, and has not more than 105 | // `limit` elements. A value of `0` for `offset` fetches from the 106 | // beginning, while a value of `0` for `limit` fetches until the end. 107 | func (_DocActions) List(offset, limit int64) ([]*DocAction, error) { 108 | if offset < 0 || limit < 0 { 109 | return nil, errors.New("offset and limit must be non-negative integers") 110 | } 111 | if limit == 0 { 112 | limit = math.MaxInt64 113 | } 114 | 115 | q := ` 116 | SELECT id, name, reconfirm 117 | FROM wf_docactions_master 118 | ORDER BY id 119 | LIMIT ? OFFSET ? 120 | ` 121 | rows, err := db.Query(q, limit, offset) 122 | if err != nil { 123 | return nil, err 124 | } 125 | defer rows.Close() 126 | 127 | ary := make([]*DocAction, 0, 10) 128 | for rows.Next() { 129 | var elem DocAction 130 | err = rows.Scan(&elem.ID, &elem.Name, &elem.Reconfirm) 131 | if err != nil { 132 | return nil, err 133 | } 134 | ary = append(ary, &elem) 135 | } 136 | if err = rows.Err(); err != nil { 137 | return nil, err 138 | } 139 | 140 | return ary, nil 141 | } 142 | 143 | // Get retrieves the document action for the given ID. 144 | func (_DocActions) Get(id DocActionID) (*DocAction, error) { 145 | if id <= 0 { 146 | return nil, errors.New("ID should be a positive integer") 147 | } 148 | 149 | var elem DocAction 150 | row := db.QueryRow("SELECT id, name, reconfirm FROM wf_docactions_master WHERE id = ?", id) 151 | err := row.Scan(&elem.ID, &elem.Name, &elem.Reconfirm) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | return &elem, nil 157 | } 158 | 159 | // GetByName answers the document action, if one such with the given 160 | // name is registered; `nil` and the error, otherwise. 161 | func (_DocActions) GetByName(name string) (*DocAction, error) { 162 | name = strings.TrimSpace(name) 163 | if name == "" { 164 | return nil, errors.New("document action cannot be empty") 165 | } 166 | 167 | var elem DocAction 168 | row := db.QueryRow("SELECT id, name, reconfirm FROM wf_docactions_master WHERE name = ?", name) 169 | err := row.Scan(&elem.ID, &elem.Name, &elem.Reconfirm) 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | return &elem, nil 175 | } 176 | 177 | // Rename renames the given document action. 178 | func (_DocActions) Rename(otx *sql.Tx, id DocActionID, name string) error { 179 | name = strings.TrimSpace(name) 180 | if name == "" { 181 | return errors.New("name cannot be empty") 182 | } 183 | 184 | var tx *sql.Tx 185 | var err error 186 | if otx == nil { 187 | tx, err = db.Begin() 188 | if err != nil { 189 | return err 190 | } 191 | defer tx.Rollback() 192 | } else { 193 | tx = otx 194 | } 195 | 196 | _, err = tx.Exec("UPDATE wf_docactions_master SET name = ? WHERE id = ?", name, id) 197 | if err != nil { 198 | return err 199 | } 200 | 201 | if otx == nil { 202 | err = tx.Commit() 203 | if err != nil { 204 | return err 205 | } 206 | } 207 | 208 | return nil 209 | } 210 | -------------------------------------------------------------------------------- /docevent.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "fmt" 21 | "math" 22 | "strings" 23 | "time" 24 | ) 25 | 26 | // EventStatus enumerates the query parameter values for filtering by 27 | // event state. 28 | type EventStatus uint8 29 | 30 | const ( 31 | // EventStatusAll does not filter events. 32 | EventStatusAll EventStatus = iota 33 | // EventStatusApplied selects only those events that have been successfully applied. 34 | EventStatusApplied 35 | // EventStatusPending selects only those events that are pending application. 36 | EventStatusPending 37 | ) 38 | 39 | // DocEventID is the type of unique document event identifiers. 40 | type DocEventID int64 41 | 42 | // DocEvent represents a user action performed on a document in the 43 | // system. 44 | // 45 | // Together with documents and nodes, events are central to the 46 | // workflow engine in `flow`. Events cause documents to transition 47 | // from one state to another, usually in response to user actions. It 48 | // is possible for system events to cause state transitions, as well. 49 | type DocEvent struct { 50 | ID DocEventID `json:"ID"` // Unique ID of this event 51 | DocType DocTypeID `json:"DocType"` // Document type of the document to which this event is to be applied 52 | DocID DocumentID `json:"DocID"` // Document to which this event is to be applied 53 | State DocStateID `json:"DocState"` // Current state of the document must equal this 54 | Action DocActionID `json:"DocAction"` // Action performed by the user 55 | Group GroupID `json:"Group"` // Group (singleton) who caused this action 56 | Text string `json:"Text"` // Comment or other content 57 | Ctime time.Time `json:"Ctime"` // Time at which the event occurred 58 | Status EventStatus `json:"Status"` // Status of this event 59 | } 60 | 61 | // StatusInDB answers the status of this event. 62 | func (e *DocEvent) StatusInDB() (EventStatus, error) { 63 | var dstatus string 64 | row := db.QueryRow("SELECT status FROM wf_docevents WHERE id = ?", e.ID) 65 | err := row.Scan(&dstatus) 66 | if err != nil { 67 | return 0, err 68 | } 69 | switch dstatus { 70 | case "A": 71 | e.Status = EventStatusApplied 72 | 73 | case "P": 74 | e.Status = EventStatusPending 75 | 76 | default: 77 | return 0, fmt.Errorf("unknown event status : %s", dstatus) 78 | } 79 | 80 | return e.Status, nil 81 | } 82 | 83 | // Unexported type, only for convenience methods. 84 | type _DocEvents struct{} 85 | 86 | // DocEvents exposes a resource-like interface to document events. 87 | var DocEvents _DocEvents 88 | 89 | // DocEventsNewInput holds information needed to create a new document 90 | // event in the system. 91 | type DocEventsNewInput struct { 92 | DocTypeID // Type of the document; required 93 | DocumentID // Unique identifier of the document; required 94 | DocStateID // Document must be in this state for this event to be applied; required 95 | DocActionID // Action performed by `Group`; required 96 | GroupID // Group (user) who performed the action that raised this event; required 97 | Text string // Any comments or notes; required 98 | } 99 | 100 | // New creates and initialises an event that transforms the document 101 | // that it refers to. 102 | func (_DocEvents) New(otx *sql.Tx, input *DocEventsNewInput) (DocEventID, error) { 103 | if input.DocTypeID <= 0 || input.DocumentID <= 0 || input.DocStateID <= 0 || input.DocActionID <= 0 || input.GroupID <= 0 { 104 | return 0, errors.New("all identifiers should be positive integers") 105 | } 106 | if input.Text == "" { 107 | return 0, errors.New("please add comments or notes") 108 | } 109 | 110 | var tx *sql.Tx 111 | var err error 112 | if otx == nil { 113 | tx, err = db.Begin() 114 | if err != nil { 115 | return 0, err 116 | } 117 | defer tx.Rollback() 118 | } else { 119 | tx = otx 120 | } 121 | 122 | // Workflow is tracked at the level of root documents. 123 | 124 | doc, err := Documents.Get(tx, input.DocTypeID, input.DocumentID) 125 | if err != nil { 126 | return 0, err 127 | } 128 | rdtid, rdid, err := doc.Path.Root() 129 | if err != nil { 130 | return 0, err 131 | } 132 | if rdid > 0 { // A different document is the root. 133 | input.DocTypeID = rdtid 134 | input.DocumentID = rdid 135 | } 136 | 137 | // Register the event using the root document. 138 | 139 | q := ` 140 | INSERT INTO wf_docevents(doctype_id, doc_id, docstate_id, docaction_id, group_id, data, ctime, status) 141 | VALUES(?, ?, ?, ?, ?, ?, NOW(), 'P') 142 | ` 143 | res, err := tx.Exec(q, input.DocTypeID, input.DocumentID, input.DocStateID, input.DocActionID, input.GroupID, input.Text) 144 | if err != nil { 145 | return 0, err 146 | } 147 | var id int64 148 | id, err = res.LastInsertId() 149 | if err != nil { 150 | return 0, err 151 | } 152 | 153 | if otx == nil { 154 | err = tx.Commit() 155 | if err != nil { 156 | return 0, err 157 | } 158 | } 159 | 160 | return DocEventID(id), nil 161 | } 162 | 163 | // DocEventsListInput specifies a set of filter conditions to narrow 164 | // down document listings. 165 | type DocEventsListInput struct { 166 | DocTypeID // Events on documents of this type are listed 167 | AccessContextID // Access context from within which to list 168 | GroupID // List events created by this (singleton) group 169 | DocStateID // List events acting on this state 170 | CtimeStarting time.Time // List events created after this time 171 | CtimeBefore time.Time // List events created before this time 172 | Status EventStatus // List events that are in this state of application 173 | } 174 | 175 | // List answers a subset of document events, based on the input 176 | // specification. 177 | // 178 | // `status` should be one of `all`, `applied` and `pending`. 179 | // 180 | // Result set begins with ID >= `offset`, and has not more than 181 | // `limit` elements. A value of `0` for `offset` fetches from the 182 | // beginning, while a value of `0` for `limit` fetches until the end. 183 | func (_DocEvents) List(input *DocEventsListInput, offset, limit int64) ([]*DocEvent, error) { 184 | if offset < 0 || limit < 0 { 185 | return nil, errors.New("offset and limit must be non-negative integers") 186 | } 187 | if limit == 0 { 188 | limit = math.MaxInt64 189 | } 190 | 191 | // Base query. 192 | 193 | q := ` 194 | SELECT de.id, de.doctype_id, de.doc_id, de.docstate_id, de.docaction_id, de.group_id, de.data, de.ctime, de.status 195 | FROM wf_docevents de 196 | ` 197 | 198 | // Process input specification. 199 | 200 | where := []string{} 201 | args := []interface{}{} 202 | 203 | if input.DocTypeID > 0 { 204 | where = append(where, `de.doctype_id = ?`) 205 | args = append(args, input.DocTypeID) 206 | } 207 | 208 | if input.AccessContextID > 0 { 209 | tbl := DocTypes.docStorName(input.DocTypeID) 210 | q += `JOIN ` + tbl + ` docs ON docs.id = de.doc_id 211 | ` 212 | where = append(where, `docs.ac_id = ?`) 213 | args = append(args, input.AccessContextID) 214 | } 215 | 216 | switch input.Status { 217 | case EventStatusAll: 218 | // Intentionally left blank 219 | 220 | case EventStatusApplied: 221 | where = append(where, `status = 'A'`) 222 | 223 | case EventStatusPending: 224 | where = append(where, `status = 'P'`) 225 | 226 | default: 227 | return nil, fmt.Errorf("unknown event status specified in filter : %d", input.Status) 228 | } 229 | 230 | if input.GroupID > 0 { 231 | where = append(where, `de.group_id = ?`) 232 | args = append(args, input.GroupID) 233 | } 234 | 235 | if input.DocStateID > 0 { 236 | where = append(where, `de.docstate_id = ?`) 237 | args = append(args, input.DocStateID) 238 | } 239 | 240 | if !input.CtimeStarting.IsZero() { 241 | where = append(where, `de.ctime >= ?`) 242 | args = append(args, input.CtimeStarting) 243 | } 244 | 245 | if !input.CtimeBefore.IsZero() { 246 | where = append(where, `de.ctime < ?`) 247 | args = append(args, input.CtimeBefore) 248 | } 249 | 250 | if len(where) > 0 { 251 | q += ` WHERE ` + strings.Join(where, ` AND `) 252 | } 253 | 254 | q += ` 255 | ORDER BY de.id 256 | LIMIT ? OFFSET ? 257 | ` 258 | args = append(args, limit, offset) 259 | rows, err := db.Query(q, args...) 260 | if err != nil { 261 | return nil, err 262 | } 263 | defer rows.Close() 264 | 265 | var text sql.NullString 266 | var dstatus string 267 | ary := make([]*DocEvent, 0, 10) 268 | for rows.Next() { 269 | var elem DocEvent 270 | err = rows.Scan(&elem.ID, &elem.DocType, &elem.DocID, &elem.State, &elem.Action, &elem.Group, &text, &elem.Ctime, &dstatus) 271 | if err != nil { 272 | return nil, err 273 | } 274 | if text.Valid { 275 | elem.Text = text.String 276 | } 277 | switch dstatus { 278 | case "A": 279 | elem.Status = EventStatusApplied 280 | 281 | case "P": 282 | elem.Status = EventStatusPending 283 | 284 | default: 285 | return nil, fmt.Errorf("unknown event status : %s", dstatus) 286 | } 287 | ary = append(ary, &elem) 288 | } 289 | if err = rows.Err(); err != nil { 290 | return nil, err 291 | } 292 | 293 | return ary, nil 294 | } 295 | 296 | // Get retrieves a document event from the database, using the given 297 | // event ID. 298 | func (_DocEvents) Get(eid DocEventID) (*DocEvent, error) { 299 | if eid <= 0 { 300 | return nil, errors.New("event ID should be a positive integer") 301 | } 302 | 303 | var text sql.NullString 304 | var dstatus string 305 | var elem DocEvent 306 | q := ` 307 | SELECT id, doctype_id, doc_id, docstate_id, docaction_id, group_id, data, ctime, status 308 | FROM wf_docevents 309 | WHERE id = ? 310 | ` 311 | row := db.QueryRow(q, eid) 312 | err := row.Scan(&elem.ID, &elem.DocType, &elem.DocID, &elem.State, &elem.Action, &elem.Group, &text, &elem.Ctime, &dstatus) 313 | if err != nil { 314 | return nil, err 315 | } 316 | if text.Valid { 317 | elem.Text = text.String 318 | } 319 | switch dstatus { 320 | case "A": 321 | elem.Status = EventStatusApplied 322 | 323 | case "P": 324 | elem.Status = EventStatusPending 325 | 326 | default: 327 | return nil, fmt.Errorf("unknown event status : %s", dstatus) 328 | } 329 | 330 | return &elem, nil 331 | } 332 | -------------------------------------------------------------------------------- /docstate.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "math" 21 | "strings" 22 | ) 23 | 24 | // DocStateID is the type of unique identifiers of document states. 25 | type DocStateID int64 26 | 27 | // DocState is one of a set of enumerated states for a top-level 28 | // document, as defined by the consuming application. 29 | // 30 | // `flow`, therefore, does not assume anything about the specifics of 31 | // any state. Applications can, for example, embed `DocState` in a 32 | // struct that provides more context. Document states should be 33 | // loaded during application initialisation. 34 | // 35 | // N.B. A `DocState` once defined and used, should *NEVER* be removed. 36 | // At best, it can be deprecated by defining a new one, and then 37 | // altering the corresponding workflow definition to use the new one 38 | // instead. 39 | type DocState struct { 40 | ID DocStateID `json:"ID"` // Unique identifier of this document state 41 | Name string `json:"Name,omitempty"` // Unique identifier of this state in its workflow 42 | } 43 | 44 | // Unexported type, only for convenience methods. 45 | type _DocStates struct{} 46 | 47 | // DocStates provides a resource-like interface to document actions 48 | // in the system. 49 | var DocStates _DocStates 50 | 51 | // New creates an enumerated state as defined by the consuming 52 | // application. 53 | func (_DocStates) New(otx *sql.Tx, name string) (DocStateID, error) { 54 | name = strings.TrimSpace(name) 55 | if name == "" { 56 | return 0, errors.New("name cannot be empty") 57 | } 58 | 59 | var tx *sql.Tx 60 | var err error 61 | if otx == nil { 62 | tx, err = db.Begin() 63 | if err != nil { 64 | return 0, err 65 | } 66 | defer tx.Rollback() 67 | } else { 68 | tx = otx 69 | } 70 | 71 | res, err := tx.Exec("INSERT INTO wf_docstates_master(name) VALUES(?)", name) 72 | if err != nil { 73 | return 0, err 74 | } 75 | var id int64 76 | id, err = res.LastInsertId() 77 | if err != nil { 78 | return 0, err 79 | } 80 | 81 | if otx == nil { 82 | err = tx.Commit() 83 | if err != nil { 84 | return 0, err 85 | } 86 | } 87 | 88 | return DocStateID(id), nil 89 | } 90 | 91 | // List answers a subset of the document states, based on the input 92 | // specification. 93 | // 94 | // Result set begins with ID >= `offset`, and has not more than 95 | // `limit` elements. A value of `0` for `offset` fetches from the 96 | // beginning, while a value of `0` for `limit` fetches until the end. 97 | func (_DocStates) List(offset, limit int64) ([]*DocState, error) { 98 | if offset < 0 || limit < 0 { 99 | return nil, errors.New("offset and limit must be non-negative integers") 100 | } 101 | if limit == 0 { 102 | limit = math.MaxInt64 103 | } 104 | 105 | q := ` 106 | SELECT id, name 107 | FROM wf_docstates_master 108 | ORDER BY id 109 | LIMIT ? OFFSET ? 110 | ` 111 | rows, err := db.Query(q, limit, offset) 112 | if err != nil { 113 | return nil, err 114 | } 115 | defer rows.Close() 116 | 117 | ary := make([]*DocState, 0, 10) 118 | for rows.Next() { 119 | var elem DocState 120 | err = rows.Scan(&elem.ID, &elem.Name) 121 | if err != nil { 122 | return nil, err 123 | } 124 | ary = append(ary, &elem) 125 | } 126 | if err = rows.Err(); err != nil { 127 | return nil, err 128 | } 129 | 130 | return ary, nil 131 | } 132 | 133 | // Get retrieves the document state for the given ID. 134 | func (_DocStates) Get(id DocStateID) (*DocState, error) { 135 | if id <= 0 { 136 | return nil, errors.New("ID should be a positive integer") 137 | } 138 | 139 | var elem DocState 140 | q := ` 141 | SELECT name 142 | FROM wf_docstates_master 143 | WHERE id = ? 144 | ` 145 | row := db.QueryRow(q, id) 146 | err := row.Scan(&elem.Name) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | elem.ID = id 152 | return &elem, nil 153 | } 154 | 155 | // GetByName answers the document state, if one with the given name is 156 | // registered; `nil` and the error, otherwise. 157 | func (_DocStates) GetByName(name string) (*DocState, error) { 158 | name = strings.TrimSpace(name) 159 | if name == "" { 160 | return nil, errors.New("document state name should be non-empty") 161 | } 162 | 163 | var elem DocState 164 | row := db.QueryRow("SELECT id, name FROM wf_docstates_master WHERE name = ?", name) 165 | err := row.Scan(&elem.ID, &elem.Name) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | return &elem, nil 171 | } 172 | 173 | // Rename renames the given document state. 174 | func (_DocStates) Rename(otx *sql.Tx, id DocStateID, name string) error { 175 | name = strings.TrimSpace(name) 176 | if name == "" { 177 | return errors.New("name cannot be empty") 178 | } 179 | 180 | var tx *sql.Tx 181 | var err error 182 | if otx == nil { 183 | tx, err = db.Begin() 184 | if err != nil { 185 | return err 186 | } 187 | defer tx.Rollback() 188 | } else { 189 | tx = otx 190 | } 191 | 192 | _, err = tx.Exec("UPDATE wf_docstates_master SET name = ? WHERE id = ?", name, id) 193 | if err != nil { 194 | return err 195 | } 196 | 197 | if otx == nil { 198 | err = tx.Commit() 199 | if err != nil { 200 | return err 201 | } 202 | } 203 | 204 | return nil 205 | } 206 | -------------------------------------------------------------------------------- /doctype.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "fmt" 21 | "math" 22 | "strings" 23 | ) 24 | 25 | // DocTypeID is the type of unique identifiers of document types. 26 | type DocTypeID int64 27 | 28 | // DocType enumerates the types of documents in the system, as defined 29 | // by the consuming application. Each document type has an associated 30 | // workflow definition that drives its life cycle. 31 | // 32 | // Accordingly, `flow` does not assume anything about the specifics of 33 | // the any document type. Instead, it treats document types as plain, 34 | // but controlled, vocabulary. Nonetheless, it is highly recommended, 35 | // but not necessary, that document types be defined in a system of 36 | // hierarchical namespaces. For example: 37 | // 38 | // PUR:RFQ 39 | // 40 | // could mean that the department is 'Purchasing', while the document 41 | // type is 'Request For Quotation'. As a variant, 42 | // 43 | // PUR:ORD 44 | // 45 | // could mean that the document type is 'Purchase Order'. 46 | // 47 | // N.B. All document types must be defined as constant strings. 48 | type DocType struct { 49 | ID DocTypeID `json:"ID,omitempty"` // Unique identifier of this document type 50 | Name string `json:"Name,omitempty"` // Unique name of this document type 51 | } 52 | 53 | // Unexported type, only for convenience methods. 54 | type _DocTypes struct{} 55 | 56 | // DocTypes provides a resource-like interface to document types in 57 | // the system. 58 | var DocTypes _DocTypes 59 | 60 | // docStorName answers the appropriate table name for the given 61 | // document type. 62 | func (_DocTypes) docStorName(dtid DocTypeID) string { 63 | return fmt.Sprintf("wf_documents_%03d", dtid) 64 | } 65 | 66 | // New creates and registers a new document type in the system. 67 | func (_DocTypes) New(otx *sql.Tx, name string) (DocTypeID, error) { 68 | name = strings.TrimSpace(name) 69 | if name == "" { 70 | return 0, errors.New("name cannot be empty") 71 | } 72 | 73 | var tx *sql.Tx 74 | var err error 75 | if otx == nil { 76 | tx, err = db.Begin() 77 | if err != nil { 78 | return 0, err 79 | } 80 | defer tx.Rollback() 81 | } else { 82 | tx = otx 83 | } 84 | 85 | res, err := tx.Exec("INSERT INTO wf_doctypes_master(name) VALUES(?)", name) 86 | if err != nil { 87 | return 0, err 88 | } 89 | var id int64 90 | id, err = res.LastInsertId() 91 | if err != nil { 92 | return 0, err 93 | } 94 | 95 | tbl := DocTypes.docStorName(DocTypeID(id)) 96 | q := `DROP TABLE IF EXISTS ` + tbl 97 | res, err = tx.Exec(q) 98 | if err != nil { 99 | return 0, err 100 | } 101 | q = ` 102 | CREATE TABLE ` + tbl + ` ( 103 | id INT NOT NULL AUTO_INCREMENT, 104 | path VARCHAR(1000) NOT NULL, 105 | ac_id INT NOT NULL, 106 | docstate_id INT NOT NULL, 107 | group_id INT NOT NULL, 108 | ctime TIMESTAMP NOT NULL, 109 | title VARCHAR(250) NULL, 110 | data TEXT NOT NULL, 111 | PRIMARY KEY (id), 112 | FOREIGN KEY (ac_id) REFERENCES wf_access_contexts(id), 113 | FOREIGN KEY (docstate_id) REFERENCES wf_docstates_master(id), 114 | FOREIGN KEY (group_id) REFERENCES wf_groups_master(id) 115 | ) 116 | ` 117 | res, err = tx.Exec(q) 118 | if err != nil { 119 | return 0, err 120 | } 121 | 122 | if otx == nil { 123 | err = tx.Commit() 124 | if err != nil { 125 | return 0, err 126 | } 127 | } 128 | return DocTypeID(id), nil 129 | } 130 | 131 | // List answers a subset of the document types, based on the input 132 | // specification. 133 | // 134 | // Result set begins with ID >= `offset`, and has not more than 135 | // `limit` elements. A value of `0` for `offset` fetches from the 136 | // beginning, while a value of `0` for `limit` fetches until the end. 137 | func (_DocTypes) List(offset, limit int64) ([]*DocType, error) { 138 | if offset < 0 || limit < 0 { 139 | return nil, errors.New("offset and limit must be non-negative integers") 140 | } 141 | if limit == 0 { 142 | limit = math.MaxInt64 143 | } 144 | 145 | q := ` 146 | SELECT id, name 147 | FROM wf_doctypes_master 148 | ORDER BY id 149 | LIMIT ? OFFSET ? 150 | ` 151 | rows, err := db.Query(q, limit, offset) 152 | if err != nil { 153 | return nil, err 154 | } 155 | defer rows.Close() 156 | 157 | ary := make([]*DocType, 0, 10) 158 | for rows.Next() { 159 | var elem DocType 160 | err = rows.Scan(&elem.ID, &elem.Name) 161 | if err != nil { 162 | return nil, err 163 | } 164 | ary = append(ary, &elem) 165 | } 166 | if err = rows.Err(); err != nil { 167 | return nil, err 168 | } 169 | 170 | return ary, nil 171 | } 172 | 173 | // Get retrieves the document type for the given ID. 174 | func (_DocTypes) Get(id DocTypeID) (*DocType, error) { 175 | if id <= 0 { 176 | return nil, errors.New("ID should be a positive integer") 177 | } 178 | 179 | var elem DocType 180 | row := db.QueryRow("SELECT id, name FROM wf_doctypes_master WHERE id = ?", id) 181 | err := row.Scan(&elem.ID, &elem.Name) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | return &elem, nil 187 | } 188 | 189 | // GetByName answers the document type, if one with the given name is 190 | // registered; `nil` and the error, otherwise. 191 | func (_DocTypes) GetByName(name string) (*DocType, error) { 192 | name = strings.TrimSpace(name) 193 | if name == "" { 194 | return nil, errors.New("document type cannot be empty") 195 | } 196 | 197 | var elem DocType 198 | row := db.QueryRow("SELECT id, name FROM wf_doctypes_master WHERE name = ?", name) 199 | err := row.Scan(&elem.ID, &elem.Name) 200 | if err != nil { 201 | return nil, err 202 | } 203 | 204 | return &elem, nil 205 | } 206 | 207 | // Rename renames the given document type. 208 | func (_DocTypes) Rename(otx *sql.Tx, id DocTypeID, name string) error { 209 | name = strings.TrimSpace(name) 210 | if name == "" { 211 | return errors.New("name cannot be empty") 212 | } 213 | 214 | var tx *sql.Tx 215 | var err error 216 | if otx == nil { 217 | tx, err = db.Begin() 218 | if err != nil { 219 | return err 220 | } 221 | defer tx.Rollback() 222 | } else { 223 | tx = otx 224 | } 225 | 226 | _, err = tx.Exec("UPDATE wf_doctypes_master SET name = ? WHERE id = ?", name, id) 227 | if err != nil { 228 | return err 229 | } 230 | 231 | if otx == nil { 232 | err = tx.Commit() 233 | if err != nil { 234 | return err 235 | } 236 | } 237 | 238 | return nil 239 | } 240 | 241 | // Transition holds the information of which action results in which 242 | // state. 243 | type Transition struct { 244 | Upon DocAction // If user/system has performed this action 245 | To DocState // Document transitions into this state 246 | } 247 | 248 | // TransitionMap holds the state transitions defined for this document 249 | // type. It lays out which actions result in which target states, 250 | // given current states. 251 | type TransitionMap struct { 252 | From DocState // When document is in this state 253 | Transitions map[DocActionID]Transition 254 | } 255 | 256 | // Transitions answers the possible document states into which a 257 | // document currently in the given state can transition. 258 | func (_DocTypes) Transitions(dtype DocTypeID, from DocStateID) (map[DocStateID]*TransitionMap, error) { 259 | q := ` 260 | SELECT dst.from_state_id, dsm1.name, dst.docaction_id, dam.name, dam.reconfirm, dst.to_state_id, dsm2.name 261 | FROM wf_docstate_transitions dst 262 | JOIN wf_docstates_master dsm1 ON dsm1.id = dst.from_state_id 263 | JOIN wf_docstates_master dsm2 ON dsm2.id = dst.to_state_id 264 | JOIN wf_docactions_master dam ON dam.id = dst.docaction_id 265 | WHERE dst.doctype_id = ? 266 | ` 267 | var rows *sql.Rows 268 | var err error 269 | if from > 0 { 270 | q += `AND dst.from_state_id = ? 271 | ` 272 | rows, err = db.Query(q, dtype, from) 273 | } else { 274 | rows, err = db.Query(q, dtype) 275 | } 276 | 277 | if err != nil { 278 | return nil, err 279 | } 280 | defer rows.Close() 281 | 282 | res := map[DocStateID]*TransitionMap{} 283 | for rows.Next() { 284 | var dsfrom DocState 285 | var t Transition 286 | err := rows.Scan(&dsfrom.ID, &dsfrom.Name, &t.Upon.ID, &t.Upon.Name, &t.Upon.Reconfirm, &t.To.ID, &t.To.Name) 287 | if err != nil { 288 | return nil, err 289 | } 290 | 291 | var elem *TransitionMap 292 | ok := false 293 | if elem, ok = res[dsfrom.ID]; !ok { 294 | elem = &TransitionMap{} 295 | elem.From = dsfrom 296 | elem.Transitions = map[DocActionID]Transition{} 297 | } 298 | 299 | elem.Transitions[t.Upon.ID] = t 300 | res[dsfrom.ID] = elem 301 | } 302 | if err = rows.Err(); err != nil { 303 | return nil, err 304 | } 305 | 306 | return res, nil 307 | } 308 | 309 | // _Transitions answers the possible document states into which a 310 | // document currently in the given state can transition. Only 311 | // identifiers are answered in the map. 312 | func (_DocTypes) _Transitions(dtype DocTypeID, state DocStateID) (map[DocActionID]DocStateID, error) { 313 | q := ` 314 | SELECT docaction_id, to_state_id 315 | FROM wf_docstate_transitions 316 | WHERE doctype_id = ? 317 | AND from_state_id = ? 318 | ` 319 | rows, err := db.Query(q, dtype, state) 320 | if err != nil { 321 | return nil, err 322 | } 323 | defer rows.Close() 324 | 325 | hash := make(map[DocActionID]DocStateID) 326 | for rows.Next() { 327 | var da DocActionID 328 | var ds DocStateID 329 | err := rows.Scan(&da, &ds) 330 | if err != nil { 331 | return nil, err 332 | } 333 | hash[da] = ds 334 | } 335 | if err = rows.Err(); err != nil { 336 | return nil, err 337 | } 338 | 339 | return hash, nil 340 | } 341 | 342 | // AddTransition associates a target document state with a document 343 | // action performed on documents in the given current state. 344 | func (_DocTypes) AddTransition(otx *sql.Tx, dtype DocTypeID, state DocStateID, 345 | action DocActionID, toState DocStateID) error { 346 | var tx *sql.Tx 347 | var err error 348 | if otx == nil { 349 | tx, err = db.Begin() 350 | if err != nil { 351 | return err 352 | } 353 | defer tx.Rollback() 354 | } else { 355 | tx = otx 356 | } 357 | 358 | q := ` 359 | INSERT INTO wf_docstate_transitions(doctype_id, from_state_id, docaction_id, to_state_id) 360 | VALUES(?, ?, ?, ?) 361 | ` 362 | _, err = tx.Exec(q, dtype, state, action, toState) 363 | if err != nil { 364 | return err 365 | } 366 | 367 | if otx == nil { 368 | err = tx.Commit() 369 | if err != nil { 370 | return err 371 | } 372 | } 373 | 374 | return nil 375 | } 376 | 377 | // RemoveTransition disassociates a target document state with a 378 | // document action performed on documents in the given current state. 379 | func (_DocTypes) RemoveTransition(otx *sql.Tx, dtype DocTypeID, state DocStateID, action DocActionID) error { 380 | var tx *sql.Tx 381 | var err error 382 | if otx == nil { 383 | tx, err = db.Begin() 384 | if err != nil { 385 | return err 386 | } 387 | defer tx.Rollback() 388 | } else { 389 | tx = otx 390 | } 391 | 392 | q := ` 393 | DELETE FROM wf_docstate_transitions 394 | WHERE doctype_id = ? 395 | AND from_state_id =? 396 | AND docaction_id = ? 397 | ` 398 | _, err = tx.Exec(q, dtype, state, action) 399 | if err != nil { 400 | return err 401 | } 402 | 403 | if otx == nil { 404 | err = tx.Commit() 405 | if err != nil { 406 | return err 407 | } 408 | } 409 | 410 | return nil 411 | } 412 | -------------------------------------------------------------------------------- /document.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "crypto/sha1" 19 | "database/sql" 20 | "errors" 21 | "fmt" 22 | "io" 23 | "math" 24 | "os" 25 | "path" 26 | "regexp" 27 | "strconv" 28 | "strings" 29 | "time" 30 | ) 31 | 32 | var ( 33 | // reDocPath defines the regular expression for each component of 34 | // a document's path. 35 | reDocPath = regexp.MustCompile("[0-9]+?:[0-9]+?/") 36 | ) 37 | 38 | // DocPath helps in managing document hierarchies. It provides a set 39 | // of utility methods that ease path management. 40 | type DocPath string 41 | 42 | // Root answers the root document information. 43 | func (p *DocPath) Root() (DocTypeID, DocumentID, error) { 44 | root := reDocPath.FindString(string(*p)) 45 | if root == "" { 46 | return 0, 0, nil 47 | } 48 | 49 | parts := strings.Split(root, ":") 50 | dtid, err := strconv.ParseInt(parts[0], 10, 64) 51 | if err != nil { 52 | return 0, 0, err 53 | } 54 | did, err := strconv.ParseInt(parts[1], 10, 64) 55 | if err != nil { 56 | return 0, 0, err 57 | } 58 | 59 | return DocTypeID(dtid), DocumentID(did), nil 60 | } 61 | 62 | // Components answers a sequence of this path's components, in order. 63 | func (p *DocPath) Components() ([]struct { 64 | DocTypeID 65 | DocumentID 66 | }, error) { 67 | comps := reDocPath.FindAllString(string(*p), -1) 68 | if len(comps) == 0 { 69 | return nil, nil 70 | } 71 | 72 | ary := []struct { 73 | DocTypeID 74 | DocumentID 75 | }{} 76 | for _, comp := range comps { 77 | parts := strings.Split(comp, ":") 78 | dtid, err := strconv.ParseInt(parts[0], 10, 64) 79 | if err != nil { 80 | return nil, err 81 | } 82 | did, err := strconv.ParseInt(parts[1], 10, 64) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | ary = append(ary, struct { 88 | DocTypeID 89 | DocumentID 90 | }{DocTypeID(dtid), DocumentID(did)}) 91 | } 92 | 93 | return ary, nil 94 | } 95 | 96 | // Append adds the given document type-document ID pair to this path, 97 | // updating it as a result. 98 | func (p *DocPath) Append(dtid DocTypeID, did DocumentID) error { 99 | if dtid <= 0 || did <= 0 { 100 | return errors.New("document type ID and document ID should be positive integers") 101 | } 102 | 103 | *p = *p + DocPath(fmt.Sprintf("%d:%d/", dtid, did)) 104 | return nil 105 | } 106 | 107 | // Blob is a simple data holder for information concerning the 108 | // user-supplied name of the binary object, the path of the stored 109 | // binary object, and its SHA1 checksum. 110 | type Blob struct { 111 | Name string `json:"Name"` // User-given name to the binary object 112 | Path string `json:"Path,omitempty"` // Path to the stored binary object 113 | SHA1Sum string `json:"SHA1sum"` // SHA1 checksum of the binary object 114 | } 115 | 116 | // DocumentID is the type of unique document identifiers. 117 | type DocumentID int64 118 | 119 | // Document represents a task in a workflow, whose life cycle it 120 | // tracks. 121 | // 122 | // Documents are central to the workflow engine and its operations. In 123 | // the process, it accumulates various details, and tracks the times 124 | // of its modifications. The life cycle typically involves several 125 | // state transitions, whose details are also tracked. 126 | // 127 | // `Document` is a recursive structure: it can contain other 128 | // documents. Therefore, when a document is created, it is 129 | // initialised with the path that leads from its root document to its 130 | // immediate parent. For root documents, this path is empty. 131 | // 132 | // Most applications should embed `Document` in their document 133 | // structures rather than use this directly. That enables them to 134 | // control their data persistence mechanisms, while delegating 135 | // workflow management to `flow`. 136 | type Document struct { 137 | ID DocumentID `json:"ID"` // Globally-unique identifier of this document 138 | DocType DocType `json:"DocType"` // For namespacing 139 | Path DocPath `json:"Path"` // Path leading to, but not including, this document 140 | 141 | AccCtx AccessContext `json:"AccessContext"` // Originating access context of this document; applicable only to a root document 142 | State DocState `json:"DocState"` // Current state of this document; applicable only to a root document 143 | 144 | Group Group `json:"Group"` // Creator of this document 145 | Ctime time.Time `json:"Ctime"` // Creation time of this (possibly child) document 146 | 147 | Title string `json:"Title"` // Human-readable title; applicable only for root documents 148 | Data string `json:"Data,omitempty"` // Primary content of the document 149 | } 150 | 151 | // Unexported type, only for convenience methods. 152 | type _Documents struct{} 153 | 154 | // Documents provides a resource-like interface to the documents in 155 | // this system. 156 | var Documents _Documents 157 | 158 | // DocumentsNewInput specifies the initial data with which a new 159 | // document has to be created in the system. 160 | type DocumentsNewInput struct { 161 | DocTypeID // Type of the new document; required 162 | AccessContextID // Access context in which the document should be created; required 163 | GroupID // (Singleton) group of the creator; required 164 | ParentType DocTypeID // Document type of the parent document, if any 165 | ParentID DocumentID // Unique identifier of the parent document, if any 166 | Title string // Title of the new document; applicable to only root (top-level) documents 167 | Data string // Body of the new document; required 168 | } 169 | 170 | // New creates and initialises a document. 171 | // 172 | // The document created through this method has a life cycle that is 173 | // associated with it through a particular workflow. In addition, the 174 | // operations that different users can perform on this document, are 175 | // determined in the scope of the access context applicable to the 176 | // current state of the document. 177 | // 178 | // N.B. Blobs, tags and children documents have to be associated with 179 | // this document, if needed, through appropriate separate calls. 180 | func (_Documents) New(otx *sql.Tx, input *DocumentsNewInput) (DocumentID, error) { 181 | if input.DocTypeID <= 0 || input.AccessContextID <= 0 || input.GroupID <= 0 { 182 | return 0, errors.New("all identifiers should be positive integers") 183 | } 184 | if len(input.Data) == 0 { 185 | return 0, errors.New("document's body should be non-empty") 186 | } 187 | 188 | var dsid int64 189 | var path DocPath 190 | var err error 191 | if input.ParentID > 0 { 192 | pdoc, err := Documents.Get(nil, input.ParentType, input.ParentID) 193 | if err != nil { 194 | return 0, err 195 | } 196 | path = pdoc.Path 197 | path.Append(input.ParentType, input.ParentID) 198 | 199 | // Child document does not have its own state. 200 | dsid = 1 // `__RESERVED_CHILD_STATE__` 201 | } else { 202 | q := ` 203 | SELECT docstate_id 204 | FROM wf_workflows 205 | WHERE doctype_id = ? 206 | AND active = 1 207 | ` 208 | row := db.QueryRow(q, input.DocTypeID) 209 | err = row.Scan(&dsid) 210 | if err != nil { 211 | switch { 212 | case err == sql.ErrNoRows: 213 | return 0, errors.New("no active workflow is defined for the given document type") 214 | 215 | default: 216 | return 0, err 217 | } 218 | } 219 | } 220 | 221 | var tx *sql.Tx 222 | if otx == nil { 223 | tx, err = db.Begin() 224 | if err != nil { 225 | return 0, err 226 | } 227 | defer tx.Rollback() 228 | } else { 229 | tx = otx 230 | } 231 | 232 | tbl := DocTypes.docStorName(input.DocTypeID) 233 | q2 := `INSERT INTO ` + tbl + `(path, ac_id, docstate_id, group_id, ctime, title, data) 234 | VALUES (?, ?, ?, ?, NOW(), ?, ?) 235 | ` 236 | res, err := tx.Exec(q2, string(path), input.AccessContextID, dsid, input.GroupID, input.Title, input.Data) 237 | if err != nil { 238 | return 0, err 239 | } 240 | id, err := res.LastInsertId() 241 | if err != nil { 242 | return 0, err 243 | } 244 | 245 | if input.ParentID > 0 { 246 | q2 = ` 247 | INSERT INTO wf_document_children(parent_doctype_id, parent_id, child_doctype_id, child_id) 248 | VALUES (?, ?, ?, ?) 249 | ` 250 | res, err = tx.Exec(q2, input.ParentType, input.ParentID, input.DocTypeID, id) 251 | if err != nil { 252 | return 0, err 253 | } 254 | } 255 | 256 | if otx == nil { 257 | err = tx.Commit() 258 | if err != nil { 259 | return 0, err 260 | } 261 | } 262 | 263 | return DocumentID(id), nil 264 | } 265 | 266 | // DocumentsListInput specifies a set of filter conditions to narrow 267 | // down document listings. 268 | type DocumentsListInput struct { 269 | DocTypeID // Documents of this type are listed; required 270 | AccessContextID // Access context from within which to list; required 271 | GroupID // List documents created by this (singleton) group 272 | DocStateID // List documents currently in this state 273 | CtimeStarting time.Time // List documents created after this time 274 | CtimeBefore time.Time // List documents created before this time 275 | TitleContains string // List documents whose title contains the given text; expensive operation 276 | RootOnly bool // List only root (top-level) documents 277 | } 278 | 279 | // List answers a subset of the documents based on the input 280 | // specification. 281 | // 282 | // Result set begins with ID >= `offset`, and has not more than 283 | // `limit` elements. A value of `0` for `offset` fetches from the 284 | // beginning, while a value of `0` for `limit` fetches until the end. 285 | func (_Documents) List(input *DocumentsListInput, offset, limit int64) ([]*Document, error) { 286 | if offset < 0 || limit < 0 { 287 | return nil, errors.New("offset and limit must be non-negative integers") 288 | } 289 | if limit == 0 { 290 | limit = math.MaxInt64 291 | } 292 | 293 | // Base query. 294 | 295 | tbl := DocTypes.docStorName(input.DocTypeID) 296 | q := ` 297 | SELECT docs.id, docs.path, docs.ac_id, docs.group_id, gm.name, docs.docstate_id, dsm.name, docs.ctime, docs.title 298 | FROM ` + tbl + ` docs 299 | JOIN wf_groups_master gm ON gm.id = docs.group_id 300 | JOIN wf_docstates_master dsm ON dsm.id = docs.docstate_id 301 | ` 302 | 303 | // Process input specification. 304 | 305 | where := []string{} 306 | args := []interface{}{input.AccessContextID} 307 | q += `WHERE docs.ac_id = ? 308 | ` 309 | 310 | if input.GroupID > 0 { 311 | where = append(where, `docs.group_id = ?`) 312 | args = append(args, input.GroupID) 313 | } 314 | 315 | if input.DocStateID > 0 { 316 | where = append(where, `docs.docstate_id = ?`) 317 | args = append(args, input.DocStateID) 318 | } 319 | 320 | if !input.CtimeStarting.IsZero() { 321 | where = append(where, `docs.ctime >= ?`) 322 | args = append(args, input.CtimeStarting) 323 | } 324 | 325 | if !input.CtimeBefore.IsZero() { 326 | where = append(where, `docs.ctime < ?`) 327 | args = append(args, input.CtimeBefore) 328 | } 329 | 330 | if input.TitleContains != "" { 331 | where = append(where, `docs.title LIKE ?`) 332 | args = append(args, "%"+input.TitleContains+"%") 333 | } 334 | 335 | if input.RootOnly { 336 | where = append(where, `docs.path = ''`) 337 | } 338 | 339 | if len(where) > 0 { 340 | q += ` AND ` + strings.Join(where, ` AND `) 341 | } 342 | 343 | q += ` 344 | ORDER BY docs.id 345 | LIMIT ? OFFSET ? 346 | ` 347 | args = append(args, limit, offset) 348 | 349 | // Fetch document data. 350 | 351 | rows, err := db.Query(q, args...) 352 | if err != nil { 353 | return nil, err 354 | } 355 | defer rows.Close() 356 | 357 | ary := make([]*Document, 0, 10) 358 | for rows.Next() { 359 | var elem Document 360 | var title sql.NullString 361 | err = rows.Scan(&elem.ID, &elem.Path, &elem.AccCtx.ID, &elem.Group.ID, &elem.Group.Name, &elem.State.ID, &elem.State.Name, &elem.Ctime, &title) 362 | if err != nil { 363 | return nil, err 364 | } 365 | 366 | elem.DocType.ID = input.DocTypeID 367 | q2 := `SELECT name FROM wf_doctypes_master WHERE id = ?` 368 | row2 := db.QueryRow(q2, input.DocTypeID) 369 | err = row2.Scan(&elem.DocType.Name) 370 | if err != nil { 371 | return nil, err 372 | } 373 | 374 | if title.Valid { 375 | elem.Title = title.String 376 | } 377 | ary = append(ary, &elem) 378 | } 379 | if err = rows.Err(); err != nil { 380 | return nil, err 381 | } 382 | 383 | return ary, nil 384 | } 385 | 386 | // Get initialises a document by reading from the database. 387 | // 388 | // N.B. This retrieves the primary data of the document. Other 389 | // information viz. blobs, tags and children documents have to be 390 | // fetched separately. 391 | func (_Documents) Get(otx *sql.Tx, dtype DocTypeID, id DocumentID) (*Document, error) { 392 | tbl := DocTypes.docStorName(dtype) 393 | var elem Document 394 | q := ` 395 | SELECT docs.path, docs.ac_id, docs.group_id, gm.name, docs.ctime, docs.title, docs.data, docs.docstate_id, dsm.name 396 | FROM ` + tbl + ` AS docs 397 | JOIN wf_groups_master gm ON gm.id = docs.group_id 398 | JOIN wf_docstates_master dsm ON docs.docstate_id = dsm.id 399 | WHERE docs.id = ? 400 | ` 401 | 402 | var row *sql.Row 403 | if otx == nil { 404 | row = db.QueryRow(q, id) 405 | } else { 406 | row = otx.QueryRow(q, id) 407 | } 408 | err := row.Scan(&elem.Path, &elem.AccCtx.ID, &elem.Group.ID, &elem.Group.Name, &elem.Ctime, &elem.Title, &elem.Data, &elem.State.ID, &elem.State.Name) 409 | if err != nil { 410 | return nil, err 411 | } 412 | q = `SELECT name FROM wf_doctypes_master WHERE id = ?` 413 | row = db.QueryRow(q, dtype) 414 | err = row.Scan(&elem.DocType.Name) 415 | if err != nil { 416 | return nil, err 417 | } 418 | 419 | elem.ID = id 420 | elem.DocType.ID = dtype 421 | return &elem, nil 422 | } 423 | 424 | // GetParent answers the parent document of the specified document. 425 | func (_Documents) GetParent(otx *sql.Tx, dtype DocTypeID, id DocumentID) (*Document, error) { 426 | q := ` 427 | SELECT parent_doctype_id, parent_id 428 | FROM wf_document_children 429 | WHERE child_doctype_id = ? 430 | AND child_id = ? 431 | LIMIT 1 432 | ` 433 | var row *sql.Row 434 | if otx == nil { 435 | row = db.QueryRow(q, dtype, id) 436 | } else { 437 | row = otx.QueryRow(q, dtype, id) 438 | } 439 | var ptid, pid int64 440 | err := row.Scan(&ptid, &pid) 441 | if err != nil { 442 | if err == sql.ErrNoRows { 443 | return nil, ErrDocumentNoParent 444 | } 445 | return nil, err 446 | } 447 | 448 | return Documents.Get(otx, DocTypeID(ptid), DocumentID(pid)) 449 | } 450 | 451 | // setState sets the new state of the document. 452 | // 453 | // This method is not exported. It is used internally by `Workflow` 454 | // to move the document along the workflow, into a new document state. 455 | func (_Documents) setState(otx *sql.Tx, dtype DocTypeID, id DocumentID, state DocStateID, ac AccessContextID) error { 456 | tbl := DocTypes.docStorName(dtype) 457 | 458 | var q string 459 | var err error 460 | if ac > 0 { 461 | q = `UPDATE ` + tbl + ` SET docstate_id = ?, ac_id = ? WHERE id = ?` 462 | _, err = otx.Exec(q, state, ac, id) 463 | } else { 464 | q = `UPDATE ` + tbl + ` SET docstate_id = ? WHERE id = ?` 465 | _, err = otx.Exec(q, state, id) 466 | } 467 | return err 468 | } 469 | 470 | // SetTitle sets the title of the document. 471 | func (_Documents) SetTitle(otx *sql.Tx, dtype DocTypeID, id DocumentID, title string) error { 472 | title = strings.TrimSpace(title) 473 | if title == "" { 474 | return errors.New("document title should not be empty") 475 | } 476 | 477 | // A child document does not have its own title. 478 | tbl := DocTypes.docStorName(dtype) 479 | var path DocPath 480 | var dgroup GroupID 481 | q := `SELECT path, group_id FROM ` + tbl + ` WHERE id = ?` 482 | row := db.QueryRow(q, id) 483 | err := row.Scan(&path, &dgroup) 484 | if err != nil { 485 | return err 486 | } 487 | if path != "" { 488 | return errors.New("a child document cannot have its own title") 489 | } 490 | 491 | var tx *sql.Tx 492 | if otx == nil { 493 | tx, err = db.Begin() 494 | if err != nil { 495 | return err 496 | } 497 | defer tx.Rollback() 498 | } else { 499 | tx = otx 500 | } 501 | 502 | q = `UPDATE ` + tbl + ` SET title = ?, ctime = NOW() WHERE id = ?` 503 | _, err = tx.Exec(q, title, id) 504 | if err != nil { 505 | return err 506 | } 507 | 508 | if otx == nil { 509 | err = tx.Commit() 510 | if err != nil { 511 | return err 512 | } 513 | } 514 | return nil 515 | } 516 | 517 | // SetData sets the data of the document. 518 | func (_Documents) SetData(otx *sql.Tx, dtype DocTypeID, id DocumentID, data string) error { 519 | if data == "" { 520 | return errors.New("document data should not be empty") 521 | } 522 | 523 | tbl := DocTypes.docStorName(dtype) 524 | 525 | var tx *sql.Tx 526 | var err error 527 | if otx == nil { 528 | tx, err = db.Begin() 529 | if err != nil { 530 | return err 531 | } 532 | defer tx.Rollback() 533 | } else { 534 | tx = otx 535 | } 536 | 537 | q := `UPDATE ` + tbl + ` SET data = ?, ctime = NOW() WHERE id = ?` 538 | _, err = tx.Exec(q, data, id) 539 | if err != nil { 540 | return err 541 | } 542 | 543 | if otx == nil { 544 | err = tx.Commit() 545 | if err != nil { 546 | return err 547 | } 548 | } 549 | return nil 550 | } 551 | 552 | // Blobs answers a list of this document's enclosures (as names, not 553 | // the actual blobs). 554 | func (_Documents) Blobs(dtype DocTypeID, id DocumentID) ([]*Blob, error) { 555 | bs := make([]*Blob, 0, 1) 556 | q := ` 557 | SELECT name, sha1sum 558 | FROM wf_document_blobs 559 | WHERE doctype_id = ? 560 | AND doc_id = ? 561 | ` 562 | rows, err := db.Query(q, dtype, id) 563 | if err != nil { 564 | return nil, err 565 | } 566 | defer rows.Close() 567 | 568 | for rows.Next() { 569 | var b Blob 570 | err = rows.Scan(&b.Name, &b.SHA1Sum) 571 | if err != nil { 572 | return nil, err 573 | } 574 | bs = append(bs, &b) 575 | } 576 | err = rows.Err() 577 | if err != nil { 578 | return nil, err 579 | } 580 | 581 | return bs, nil 582 | } 583 | 584 | // GetBlob retrieves the requested blob from the specified document, 585 | // if one such exists. Lookup happens based on the given blob name. 586 | // The retrieved blob is copied into the specified path. 587 | func (_Documents) GetBlob(dtype DocTypeID, id DocumentID, blob *Blob) error { 588 | if blob == nil { 589 | return errors.New("blob should be non-nil") 590 | } 591 | 592 | q := ` 593 | SELECT name, path 594 | FROM wf_document_blobs 595 | WHERE doctype_id = ? 596 | AND doc_id = ? 597 | AND sha1sum = ? 598 | ` 599 | row := db.QueryRow(q, dtype, id, blob.SHA1Sum) 600 | var b Blob 601 | err := row.Scan(&b.Name, &b.Path) 602 | if err != nil { 603 | return err 604 | } 605 | b.SHA1Sum = blob.SHA1Sum 606 | 607 | // Copy the blob into the destination path given. 608 | 609 | inf, err := os.Open(b.Path) 610 | if err != nil { 611 | return err 612 | } 613 | defer inf.Close() 614 | outf, err := os.Create(blob.Path) 615 | if err != nil { 616 | return err 617 | } 618 | defer outf.Close() 619 | _, err = io.Copy(outf, inf) 620 | if err != nil { 621 | return err 622 | } 623 | 624 | return nil 625 | } 626 | 627 | // AddBlob adds the path to an enclosure to this document. 628 | func (_Documents) AddBlob(otx *sql.Tx, dtype DocTypeID, id DocumentID, blob *Blob) error { 629 | if blob == nil { 630 | return errors.New("blob should be non-nil") 631 | } 632 | 633 | // Verify the given checksum. 634 | f, err := os.Open(blob.Path) 635 | if err != nil { 636 | return err 637 | } 638 | defer f.Close() 639 | h := sha1.New() 640 | _, err = io.Copy(h, f) 641 | if err != nil { 642 | return err 643 | } 644 | csum := fmt.Sprintf("%x", h.Sum(nil)) 645 | if blob.SHA1Sum != csum { 646 | return fmt.Errorf("checksum mismatch -- given SHA1 sum : %s, computed SHA1 sum : %s", blob.SHA1Sum, csum) 647 | } 648 | 649 | // Store the blob in the appropriate path. 650 | 651 | success := false 652 | bpath := path.Join(blobsDir, csum[0:2], csum) 653 | err = os.Rename(blob.Path, bpath) 654 | if err != nil { 655 | return err 656 | } 657 | // Clean-up in case of any error. However, this mechanism is not 658 | // adequate if this method runs in the scope of an outer 659 | // transaction. The moved file will be orphaned, should the outer 660 | // transaction abort later. 661 | // 662 | // TODO(js): Implement a better solution. 663 | defer func() { 664 | if !success { 665 | os.Remove(bpath) 666 | } 667 | }() 668 | 669 | var tx *sql.Tx 670 | if otx == nil { 671 | tx, err = db.Begin() 672 | if err != nil { 673 | return err 674 | } 675 | defer tx.Rollback() 676 | } else { 677 | tx = otx 678 | } 679 | 680 | // Now write the database entry. 681 | 682 | q := ` 683 | INSERT INTO wf_document_blobs(doctype_id, doc_id, name, path, sha1sum) 684 | VALUES(?, ?, ?, ?, ?) 685 | ` 686 | _, err = tx.Exec(q, dtype, id, blob.Name, bpath, csum) 687 | if err != nil { 688 | return err 689 | } 690 | 691 | if otx == nil { 692 | err = tx.Commit() 693 | if err != nil { 694 | return err 695 | } 696 | } 697 | 698 | success = true 699 | return nil 700 | } 701 | 702 | // DeleteBlob deletes the given blob from the specified document. 703 | func (_Documents) DeleteBlob(otx *sql.Tx, dtype DocTypeID, id DocumentID, sha1 string) error { 704 | if sha1 == "" { 705 | return errors.New("SHA1 sum should be non-empty") 706 | } 707 | 708 | var tx *sql.Tx 709 | var err error 710 | if otx == nil { 711 | tx, err = db.Begin() 712 | if err != nil { 713 | return err 714 | } 715 | defer tx.Rollback() 716 | } else { 717 | tx = otx 718 | } 719 | 720 | q := ` 721 | SELECT COUNT(*) 722 | FROM wf_document_blobs 723 | WHERE sha1sum = ? 724 | ` 725 | var count int64 726 | row := tx.QueryRow(q, sha1) 727 | err = row.Scan(&count) 728 | if err != nil { 729 | return err 730 | } 731 | if count == 1 { 732 | q = ` 733 | SELECT path 734 | FROM wf_document_blobs 735 | WHERE doctype_id = ? 736 | AND doc_id = ? 737 | AND sha1sum = ? 738 | ` 739 | var path string 740 | row = tx.QueryRow(q, dtype, id, sha1) 741 | err = row.Scan(&path) 742 | if err != nil { 743 | return err 744 | } 745 | 746 | err = os.Remove(path) 747 | if err != nil { 748 | return err 749 | } 750 | } 751 | 752 | q = ` 753 | DELETE FROM wf_document_blobs 754 | WHERE doctype_id = ? 755 | AND doc_id = ? 756 | AND sha1sum = ? 757 | ` 758 | _, err = tx.Exec(q, dtype, id, sha1) 759 | if err != nil { 760 | return err 761 | } 762 | 763 | if otx == nil { 764 | err = tx.Commit() 765 | if err != nil { 766 | return err 767 | } 768 | } 769 | 770 | return nil 771 | } 772 | 773 | // Tags answers a list of the tags associated with this document. 774 | func (_Documents) Tags(dtype DocTypeID, id DocumentID) ([]string, error) { 775 | ts := make([]string, 0, 1) 776 | q := ` 777 | SELECT tag 778 | FROM wf_document_tags 779 | WHERE doctype_id = ? 780 | AND doc_id = ? 781 | ` 782 | rows, err := db.Query(q, dtype, id) 783 | if err != nil { 784 | return nil, err 785 | } 786 | defer rows.Close() 787 | 788 | for rows.Next() { 789 | var t string 790 | err = rows.Scan(&t) 791 | if err != nil { 792 | return nil, err 793 | } 794 | ts = append(ts, t) 795 | } 796 | err = rows.Err() 797 | if err != nil { 798 | return nil, err 799 | } 800 | 801 | return ts, nil 802 | } 803 | 804 | // AddTags associates the given tag with this document. 805 | // 806 | // Tags are converted to lower case (as per normal Unicode casing) 807 | // before getting associated with documents. Also, embedded spaces, 808 | // if any, are retained. 809 | func (_Documents) AddTags(otx *sql.Tx, dtype DocTypeID, id DocumentID, tags ...string) error { 810 | // A child document does not have its own tags. 811 | q := ` 812 | SELECT parent_id 813 | FROM wf_document_children 814 | WHERE child_doctype_id = ? 815 | AND child_id = ? 816 | ORDER BY child_id 817 | LIMIT 1 818 | ` 819 | var tid int64 820 | row := db.QueryRow(q, dtype, id) 821 | err := row.Scan(&tid) 822 | if err == nil { 823 | return ErrDocumentIsChild 824 | } 825 | if err != sql.ErrNoRows { 826 | return err 827 | } 828 | 829 | var tx *sql.Tx 830 | if otx == nil { 831 | tx, err = db.Begin() 832 | if err != nil { 833 | return err 834 | } 835 | defer tx.Rollback() 836 | } else { 837 | tx = otx 838 | } 839 | 840 | // Now write the database entry. 841 | 842 | q = ` 843 | INSERT INTO wf_document_tags(doctype_id, doc_id, tag) 844 | VALUES(?, ?, ?) 845 | ` 846 | for _, tag := range tags { 847 | tag = strings.TrimSpace(tag) 848 | tag = strings.ToLower(tag) 849 | _, err = tx.Exec(q, dtype, id, tag) 850 | if err != nil { 851 | return err 852 | } 853 | } 854 | 855 | if otx == nil { 856 | err = tx.Commit() 857 | if err != nil { 858 | return err 859 | } 860 | } 861 | 862 | return nil 863 | } 864 | 865 | // RemoveTag disassociates the given tag from this document. 866 | func (_Documents) RemoveTag(otx *sql.Tx, dtype DocTypeID, id DocumentID, tag string) error { 867 | tag = strings.TrimSpace(tag) 868 | if tag == "" { 869 | return errors.New("tag should not be empty") 870 | } 871 | tag = strings.ToLower(tag) 872 | 873 | var tx *sql.Tx 874 | var err error 875 | if otx == nil { 876 | tx, err = db.Begin() 877 | if err != nil { 878 | return err 879 | } 880 | defer tx.Rollback() 881 | } else { 882 | tx = otx 883 | } 884 | 885 | // Now write the database entry. 886 | q := ` 887 | DELETE FROM wf_document_tags 888 | WHERE doctype_id = ? 889 | AND doc_id = ? 890 | AND tag = ? 891 | ` 892 | _, err = tx.Exec(q, dtype, id, tag) 893 | if err != nil { 894 | return err 895 | } 896 | 897 | if otx == nil { 898 | err = tx.Commit() 899 | if err != nil { 900 | return err 901 | } 902 | } 903 | 904 | return nil 905 | } 906 | 907 | // ChildrenIDs answers a list of this document's children IDs. 908 | func (_Documents) ChildrenIDs(dtype DocTypeID, id DocumentID) ([]struct { 909 | DocTypeID 910 | DocumentID 911 | }, error) { 912 | cids := make([]struct { 913 | DocTypeID 914 | DocumentID 915 | }, 0, 1) 916 | 917 | q := ` 918 | SELECT child_doctype_id, child_id 919 | FROM wf_document_children 920 | WHERE parent_doctype_id = ? 921 | AND parent_id = ? 922 | ` 923 | rows, err := db.Query(q, dtype, id) 924 | if err != nil { 925 | return nil, err 926 | } 927 | defer rows.Close() 928 | 929 | for rows.Next() { 930 | var s struct { 931 | DocTypeID 932 | DocumentID 933 | } 934 | err = rows.Scan(&s.DocTypeID, &s.DocumentID) 935 | if err != nil { 936 | return nil, err 937 | } 938 | cids = append(cids, s) 939 | } 940 | if err = rows.Err(); err != nil { 941 | return nil, err 942 | } 943 | 944 | return cids, nil 945 | } 946 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | // Error defines `flow`-specific errors, and satisfies the `error` 18 | // interface. 19 | type Error string 20 | 21 | // Error implements the `error` interface. 22 | func (e Error) Error() string { 23 | return string(e) 24 | } 25 | 26 | // 27 | 28 | const ( 29 | // ErrUnknown : unknown internal error 30 | ErrUnknown = Error("ErrUnknown : unknown internal error") 31 | 32 | // ErrDocEventRedundant : another equivalent event has already effected this action 33 | ErrDocEventRedundant = Error("ErrDocEventRedundant : another equivalent event has already applied this action") 34 | // ErrDocEventDocTypeMismatch : document's type does not match event's type 35 | ErrDocEventDocTypeMismatch = Error("ErrDocEventDocTypeMismatch : document's type does not match event's type") 36 | // ErrDocEventStateMismatch : document's state does not match event's state 37 | ErrDocEventStateMismatch = Error("ErrDocEventStateMismatch : document's state does not match event's state") 38 | // ErrDocEventAlreadyApplied : event already applied; nothing to do 39 | ErrDocEventAlreadyApplied = Error("ErrDocEventAlreadyApplied : event already applied; nothing to do") 40 | 41 | // ErrDocumentNoParent : document is a root document 42 | ErrDocumentNoParent = Error("ErrDocumentNoParent : document is a root document") 43 | // ErrDocumentIsChild : cannot have its own state, title or tags 44 | ErrDocumentIsChild = Error("ErrDocumentIsChild : cannot have its own state, title or tags") 45 | 46 | // ErrWorkflowInactive : this workflow is currently inactive 47 | ErrWorkflowInactive = Error("ErrWorkflowInactive : this workflow is currently inactive") 48 | // ErrWorkflowInvalidAction : given action cannot be performed on this document's current state 49 | ErrWorkflowInvalidAction = Error("ErrWorkflowInvalidAction : given action cannot be performed on this document's current state") 50 | 51 | // ErrMessageNoRecipients : list of recipients is empty 52 | ErrMessageNoRecipients = Error("ErrMessageNoRecipients : list of recipients is empty") 53 | ) 54 | -------------------------------------------------------------------------------- /flow_test.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "strings" 20 | "testing" 21 | 22 | _ "github.com/go-sql-driver/mysql" 23 | ) 24 | 25 | // error0 expects only an error value as its argument. 26 | func error0(err error) error { 27 | if err != nil { 28 | gt.Errorf("%v", err) 29 | } 30 | return err 31 | } 32 | 33 | // error1 expects a value and an error value as its arguments. 34 | func error1(val1 interface{}, err error) interface{} { 35 | if err != nil { 36 | gt.Errorf("%v", err) 37 | return nil 38 | } 39 | return val1 40 | } 41 | 42 | // fatal0 expects only an error value as its argument. 43 | func fatal0(err error) { 44 | if err != nil { 45 | gt.Fatalf("%v", err) 46 | } 47 | } 48 | 49 | // fatal1 expects a value and an error value as its arguments. 50 | func fatal1(val1 interface{}, err error) interface{} { 51 | if err != nil { 52 | gt.Fatalf("%v", err) 53 | } 54 | return val1 55 | } 56 | 57 | // assertEqual compares the two given values for equality. In case of 58 | // a difference, it errors with the given message. 59 | func assertEqual(expected, observed interface{}, msgs ...string) { 60 | if expected == observed { 61 | return 62 | } 63 | 64 | gt.Errorf("expected : '%v', observed : '%v'\n\t%s", expected, observed, strings.Join(msgs, "\n\t")) 65 | } 66 | 67 | // assertNotEqual compares the two given values for inequality. In 68 | // case of equality, it errors with the given message. 69 | func assertNotEqual(expected, observed interface{}, msgs ...string) { 70 | if expected != observed { 71 | return 72 | } 73 | 74 | gt.Errorf("expected : '%v', observed : '%v'\n\t%s", expected, observed, strings.Join(msgs, "\n\t")) 75 | } 76 | 77 | // Initialise DB connection. 78 | func TestFlowInit(t *testing.T) { 79 | gt = t 80 | 81 | // Connect to the database. 82 | driver, connStr := "mysql", "travis@/flow" 83 | tdb := fatal1(sql.Open(driver, connStr)).(*sql.DB) 84 | RegisterDB(tdb) 85 | } 86 | 87 | // Test-local state. 88 | var gt *testing.T 89 | 90 | var dtID1, dtID2 DocTypeID 91 | var dsID1, dsID2, dsID3, dsID4, dsID5 DocStateID 92 | var daID1, daID2, daID3, daID4, daID5, daID6, daID7, daID8, daID9 DocActionID 93 | var wfID1, wfID2 WorkflowID 94 | 95 | var roleID1, roleID2 RoleID 96 | var uID1, uID2, uID3, uID4 UserID 97 | var gID1, gID2, gID3, gID4, gID5, gID6 GroupID 98 | 99 | // Create operations. 100 | func TestFlowCreate(t *testing.T) { 101 | gt = t 102 | 103 | t.Run("DocTypes", func(t *testing.T) { 104 | tx := fatal1(db.Begin()).(*sql.Tx) 105 | defer tx.Rollback() 106 | 107 | dtID1 = fatal1(DocTypes.New(tx, "Stor Request")).(DocTypeID) 108 | dtID2 = fatal1(DocTypes.New(tx, "Compute Request")).(DocTypeID) 109 | 110 | fatal0(tx.Commit()) 111 | }) 112 | 113 | t.Run("DocStates", func(t *testing.T) { 114 | tx := fatal1(db.Begin()).(*sql.Tx) 115 | defer tx.Rollback() 116 | 117 | dsID1 = fatal1(DocStates.New(tx, "Initial")).(DocStateID) 118 | dsID2 = fatal1(DocStates.New(tx, "Pending Approval")).(DocStateID) 119 | dsID3 = fatal1(DocStates.New(tx, "Approved")).(DocStateID) 120 | dsID4 = fatal1(DocStates.New(tx, "Rejected")).(DocStateID) 121 | dsID5 = fatal1(DocStates.New(tx, "Discarded")).(DocStateID) 122 | 123 | fatal0(tx.Commit()) 124 | }) 125 | 126 | t.Run("DocActions", func(t *testing.T) { 127 | tx := fatal1(db.Begin()).(*sql.Tx) 128 | defer tx.Rollback() 129 | 130 | daID1 = fatal1(DocActions.New(tx, "Initialise", false)).(DocActionID) 131 | daID2 = fatal1(DocActions.New(tx, "New", false)).(DocActionID) 132 | daID3 = fatal1(DocActions.New(tx, "Get", false)).(DocActionID) 133 | daID4 = fatal1(DocActions.New(tx, "Update", true)).(DocActionID) 134 | daID5 = fatal1(DocActions.New(tx, "Delete", true)).(DocActionID) 135 | daID6 = fatal1(DocActions.New(tx, "Approve", false)).(DocActionID) 136 | daID7 = fatal1(DocActions.New(tx, "Reject", false)).(DocActionID) 137 | daID8 = fatal1(DocActions.New(tx, "Return", false)).(DocActionID) 138 | daID9 = fatal1(DocActions.New(tx, "Discard", true)).(DocActionID) 139 | 140 | fatal0(tx.Commit()) 141 | }) 142 | 143 | t.Run("Workflows", func(t *testing.T) { 144 | tx := fatal1(db.Begin()).(*sql.Tx) 145 | defer tx.Rollback() 146 | 147 | wfID1 = fatal1(Workflows.New(tx, "Storage Management", dtID1, dsID1)).(WorkflowID) 148 | wfID2 = error1(Workflows.New(tx, "Compute Management", dtID2, dsID1)).(WorkflowID) 149 | 150 | fatal0(tx.Commit()) 151 | }) 152 | 153 | t.Run("Users", func(t *testing.T) { 154 | tx := fatal1(db.Begin()).(*sql.Tx) 155 | defer tx.Rollback() 156 | 157 | res, err := tx.Exec(`INSERT INTO users_master(first_name, last_name, email, active) 158 | VALUES('FN 1', 'LN 1', 'email1@example.com', 1)`) 159 | if err != nil { 160 | t.Fatalf("%v\n", err) 161 | } 162 | uid, _ := res.LastInsertId() 163 | uID1 = UserID(uid) 164 | gID1 = fatal1(Groups.NewSingleton(tx, uID1)).(GroupID) 165 | 166 | res, err = tx.Exec(`INSERT INTO users_master(first_name, last_name, email, active) 167 | VALUES('FN 2', 'LN 2', 'email2@example.com', 1)`) 168 | if err != nil { 169 | t.Fatalf("%v\n", err) 170 | } 171 | uid, _ = res.LastInsertId() 172 | uID2 = UserID(uid) 173 | gID2 = fatal1(Groups.NewSingleton(tx, uID2)).(GroupID) 174 | 175 | res, err = tx.Exec(`INSERT INTO users_master(first_name, last_name, email, active) 176 | VALUES('FN 3', 'LN 3', 'email3@example.com', 1)`) 177 | if err != nil { 178 | t.Errorf("%v\n", err) 179 | } 180 | uid, _ = res.LastInsertId() 181 | uID3 = UserID(uid) 182 | gID3 = fatal1(Groups.NewSingleton(tx, uID3)).(GroupID) 183 | 184 | res, err = tx.Exec(`INSERT INTO users_master(first_name, last_name, email, active) 185 | VALUES('FN 4', 'LN 4', 'email4@example.com', 1)`) 186 | if err != nil { 187 | t.Fatalf("%v\n", err) 188 | } 189 | uid, _ = res.LastInsertId() 190 | uID4 = UserID(uid) 191 | gID4 = fatal1(Groups.NewSingleton(tx, uID4)).(GroupID) 192 | 193 | fatal0(tx.Commit()) 194 | }) 195 | 196 | t.Run("Groups", func(t *testing.T) { 197 | tx := fatal1(db.Begin()).(*sql.Tx) 198 | defer tx.Rollback() 199 | 200 | gID5 = fatal1(Groups.New(tx, "Analysts", "G")).(GroupID) 201 | gID6 = fatal1(Groups.New(tx, "Managers", "G")).(GroupID) 202 | 203 | fatal0(tx.Commit()) 204 | }) 205 | 206 | t.Run("GroupsAddUsers", func(t *testing.T) { 207 | tx := fatal1(db.Begin()).(*sql.Tx) 208 | defer tx.Rollback() 209 | 210 | fatal0(Groups.AddUser(tx, gID5, uID1)) 211 | fatal0(Groups.AddUser(tx, gID5, uID2)) 212 | fatal0(Groups.AddUser(tx, gID5, uID3)) 213 | 214 | fatal0(Groups.AddUser(tx, gID6, uID2)) 215 | fatal0(Groups.AddUser(tx, gID6, uID3)) 216 | fatal0(Groups.AddUser(tx, gID6, uID4)) 217 | 218 | fatal0(tx.Commit()) 219 | }) 220 | 221 | t.Run("Roles", func(t *testing.T) { 222 | tx := fatal1(db.Begin()).(*sql.Tx) 223 | defer tx.Rollback() 224 | 225 | roleID1 = fatal1(Roles.New(tx, "Research Analyst")).(RoleID) 226 | roleID2 = fatal1(Roles.New(tx, "Manager")).(RoleID) 227 | 228 | fatal0(tx.Commit()) 229 | }) 230 | 231 | t.Run("RolesAddPermissions", func(t *testing.T) { 232 | tx := fatal1(db.Begin()).(*sql.Tx) 233 | defer tx.Rollback() 234 | 235 | fatal0(Roles.AddPermissions(tx, roleID1, dtID1, []DocActionID{daID1, daID2, daID3, daID4, daID8, daID9})) 236 | fatal0(Roles.AddPermissions(tx, roleID2, dtID1, []DocActionID{daID1, daID2, daID3, daID4, daID5, daID6, daID7, daID8, daID9})) 237 | 238 | fatal0(tx.Commit()) 239 | }) 240 | } 241 | 242 | // Entity listing. 243 | func TestFlowList(t *testing.T) { 244 | gt = t 245 | var res interface{} 246 | 247 | t.Run("DocTypes", func(t *testing.T) { 248 | var dts []*DocType 249 | if res = error1(DocTypes.List(0, 0)); res == nil { 250 | return 251 | } 252 | dts = res.([]*DocType) 253 | assertEqual(2, len(dts)) 254 | }) 255 | 256 | t.Run("DocStates", func(t *testing.T) { 257 | var dss []*DocState 258 | if res = error1(DocStates.List(0, 0)); res == nil { 259 | return 260 | } 261 | dss = res.([]*DocState) 262 | // There is a pre-defined reserved state for children. 263 | assertEqual(6, len(dss)) 264 | }) 265 | 266 | t.Run("DocActions", func(t *testing.T) { 267 | var das []*DocAction 268 | if res = error1(DocActions.List(0, 0)); res == nil { 269 | return 270 | } 271 | das = res.([]*DocAction) 272 | assertEqual(9, len(das)) 273 | }) 274 | 275 | t.Run("Workflows", func(t *testing.T) { 276 | if res = error1(Workflows.List(0, 0)); res == nil { 277 | return 278 | } 279 | wfs := res.([]*Workflow) 280 | assertEqual(2, len(wfs)) 281 | }) 282 | 283 | t.Run("Users", func(t *testing.T) { 284 | if res = error1(Users.List("", 0, 0)); res == nil { 285 | return 286 | } 287 | us := res.([]*User) 288 | assertEqual(4, len(us)) 289 | 290 | if res = error1(Users.List("LN 4", 0, 0)); res == nil { 291 | return 292 | } 293 | us = res.([]*User) 294 | assertEqual(1, len(us)) 295 | }) 296 | 297 | t.Run("Groups", func(t *testing.T) { 298 | var gs []*Group 299 | if res = error1(Groups.List(0, 0)); res == nil { 300 | return 301 | } 302 | gs = res.([]*Group) 303 | assertEqual(6, len(gs)) 304 | }) 305 | 306 | t.Run("Roles", func(t *testing.T) { 307 | var rs []*Role 308 | if res = error1(Roles.List(0, 0)); res == nil { 309 | return 310 | } 311 | rs = res.([]*Role) 312 | // There are two pre-defined roles for administrators. 313 | assertEqual(4, len(rs)) 314 | }) 315 | } 316 | 317 | // Retrieval of individual entities. 318 | func TestFlowGet(t *testing.T) { 319 | gt = t 320 | var res interface{} 321 | 322 | t.Run("DocTypes", func(t *testing.T) { 323 | var dt *DocType 324 | if res = error1(DocTypes.GetByName("Compute Request")); res == nil { 325 | return 326 | } 327 | dt = res.(*DocType) 328 | assertEqual("Compute Request", dt.Name) 329 | 330 | var dt2 *DocType 331 | if res = error1(DocTypes.Get(dt.ID)); res == nil { 332 | return 333 | } 334 | dt2 = res.(*DocType) 335 | assertEqual("Compute Request", dt2.Name) 336 | }) 337 | 338 | t.Run("DocStates", func(t *testing.T) { 339 | var ds *DocState 340 | if res = error1(DocStates.GetByName("Approved")); res == nil { 341 | return 342 | } 343 | ds = res.(*DocState) 344 | assertEqual("Approved", ds.Name) 345 | 346 | var ds2 *DocState 347 | if res = error1(DocStates.Get(ds.ID)); res == nil { 348 | return 349 | } 350 | ds2 = res.(*DocState) 351 | assertEqual("Approved", ds2.Name) 352 | }) 353 | 354 | t.Run("DocActions", func(t *testing.T) { 355 | var da *DocAction 356 | if res = error1(DocActions.GetByName("Reject")); res == nil { 357 | return 358 | } 359 | da = res.(*DocAction) 360 | assertEqual("Reject", da.Name) 361 | 362 | var da2 *DocAction 363 | if res = error1(DocActions.Get(da.ID)); res == nil { 364 | return 365 | } 366 | da2 = res.(*DocAction) 367 | assertEqual("Reject", da2.Name) 368 | }) 369 | 370 | t.Run("Workflows", func(t *testing.T) { 371 | if res = error1(Workflows.GetByDocType(dtID1)); res == nil { 372 | return 373 | } 374 | wf := res.(*Workflow) 375 | assertEqual("Storage Management", wf.Name) 376 | assertEqual(wfID1, wf.ID) 377 | }) 378 | 379 | t.Run("Groups", func(t *testing.T) { 380 | var g *Group 381 | if res = error1(Groups.Get(gID1)); res == nil { 382 | return 383 | } 384 | g = res.(*Group) 385 | 386 | var u *User 387 | if res = error1(Groups.SingletonUser(gID1)); res == nil { 388 | return 389 | } 390 | u = res.(*User) 391 | 392 | assertEqual(u.Email, g.Name, "singleton group name should match corresponding user's e-mail") 393 | 394 | if res = error1(Groups.HasUser(gID6, uID4)); res == nil { 395 | return 396 | } 397 | ok := res.(bool) 398 | assertEqual(true, ok) 399 | }) 400 | 401 | t.Run("Roles", func(t *testing.T) { 402 | var dt *DocType 403 | if res = error1(DocTypes.Get(dtID1)); res == nil { 404 | return 405 | } 406 | dt = res.(*DocType) 407 | 408 | if res = error1(Roles.Permissions(roleID1)); res == nil { 409 | return 410 | } 411 | perms := res.(map[string]struct { 412 | DocTypeID DocTypeID 413 | Actions []*DocAction 414 | }) 415 | assertEqual(1, len(perms)) 416 | assertEqual(6, len(perms[dt.Name].Actions)) 417 | 418 | if res = error1(Roles.HasPermission(roleID2, dtID1, daID6)); res == nil { 419 | return 420 | } 421 | assertEqual(true, res.(bool)) 422 | }) 423 | } 424 | 425 | // Entity update operations. 426 | func TestFlowUpdate(t *testing.T) { 427 | gt = t 428 | var res interface{} 429 | 430 | t.Run("DocTypeRename", func(t *testing.T) { 431 | tx := fatal1(db.Begin()).(*sql.Tx) 432 | defer tx.Rollback() 433 | 434 | if err := error0(DocTypes.Rename(tx, dtID1, "Storage Request")); err != nil { 435 | return 436 | } 437 | 438 | fatal0(tx.Commit()) 439 | 440 | if res = error1(DocTypes.Get(dtID1)); res == nil { 441 | return 442 | } 443 | obj := res.(*DocType) 444 | assertEqual("Storage Request", obj.Name) 445 | }) 446 | 447 | t.Run("DocStateRename", func(t *testing.T) { 448 | tx := fatal1(db.Begin()).(*sql.Tx) 449 | defer tx.Rollback() 450 | 451 | if err := error0(DocStates.Rename(tx, dsID1, "Draft")); err != nil { 452 | return 453 | } 454 | 455 | fatal0(tx.Commit()) 456 | 457 | if res = error1(DocStates.Get(dsID1)); res == nil { 458 | return 459 | } 460 | obj := res.(*DocState) 461 | assertEqual("Draft", obj.Name) 462 | }) 463 | 464 | t.Run("DocActionRename", func(t *testing.T) { 465 | tx := fatal1(db.Begin()).(*sql.Tx) 466 | defer tx.Rollback() 467 | 468 | if res = error0(DocActions.Rename(tx, daID1, "List")); res != nil { 469 | return 470 | } 471 | 472 | fatal0(tx.Commit()) 473 | 474 | if res = error1(DocActions.Get(daID1)); res == 0 { 475 | return 476 | } 477 | obj := res.(*DocAction) 478 | assertEqual("List", obj.Name) 479 | }) 480 | 481 | t.Run("WorkflowsSetActive", func(t *testing.T) { 482 | tx := fatal1(db.Begin()).(*sql.Tx) 483 | defer tx.Rollback() 484 | 485 | if res = error0(Workflows.SetActive(tx, wfID1, false)); res != nil { 486 | return 487 | } 488 | 489 | fatal0(tx.Commit()) 490 | 491 | if res = error1(Workflows.Get(wfID1)); res == nil { 492 | return 493 | } 494 | wf := res.(*Workflow) 495 | assertEqual(false, wf.Active) 496 | 497 | tx = fatal1(db.Begin()).(*sql.Tx) 498 | defer tx.Rollback() 499 | 500 | if res = error0(Workflows.SetActive(tx, wfID1, true)); res != nil { 501 | return 502 | } 503 | 504 | fatal0(tx.Commit()) 505 | 506 | if res = error1(Workflows.Get(wfID1)); res == nil { 507 | return 508 | } 509 | wf = res.(*Workflow) 510 | assertEqual(true, wf.Active) 511 | }) 512 | 513 | t.Run("GroupRename", func(t *testing.T) { 514 | tx := fatal1(db.Begin()).(*sql.Tx) 515 | defer tx.Rollback() 516 | 517 | if res = error0(Groups.Rename(tx, gID5, "Research Associates")); res != nil { 518 | return 519 | } 520 | 521 | fatal0(tx.Commit()) 522 | 523 | if res = error1(Groups.Get(gID5)); res == 0 { 524 | return 525 | } 526 | obj := res.(*Group) 527 | assertEqual("Research Associates", obj.Name) 528 | }) 529 | 530 | t.Run("GroupsDeleteUsers", func(t *testing.T) { 531 | tx := fatal1(db.Begin()).(*sql.Tx) 532 | defer tx.Rollback() 533 | 534 | error0(Groups.RemoveUser(tx, gID5, uID3)) 535 | 536 | fatal0(tx.Commit()) 537 | 538 | if res = error1(Groups.Users(gID5)); res == nil { 539 | return 540 | } 541 | objs := res.([]*User) 542 | assertEqual(2, len(objs)) 543 | }) 544 | 545 | t.Run("RolesRename", func(t *testing.T) { 546 | tx := fatal1(db.Begin()).(*sql.Tx) 547 | defer tx.Rollback() 548 | 549 | if err := error0(Roles.Rename(tx, roleID1, "Analyst")); err != nil { 550 | return 551 | } 552 | 553 | fatal0(tx.Commit()) 554 | 555 | if res = error1(Roles.Get(roleID1)); res == 0 { 556 | return 557 | } 558 | obj := res.(*Role) 559 | assertEqual("Analyst", obj.Name) 560 | }) 561 | 562 | t.Run("RolesDeletePerm", func(t *testing.T) { 563 | tx := fatal1(db.Begin()).(*sql.Tx) 564 | defer tx.Rollback() 565 | 566 | if err := error0(Roles.RemovePermissions(tx, roleID1, dtID1, []DocActionID{daID8})); err != nil { 567 | return 568 | } 569 | 570 | fatal0(tx.Commit()) 571 | 572 | if res = error1(Roles.HasPermission(roleID1, dtID1, daID8)); res == nil { 573 | return 574 | } 575 | assertEqual(false, res.(bool)) 576 | }) 577 | } 578 | 579 | // Entity deletion operations. 580 | func TestFlowDelete(t *testing.T) { 581 | gt = t 582 | var res interface{} 583 | 584 | t.Run("GroupsDelete", func(t *testing.T) { 585 | tx := fatal1(db.Begin()).(*sql.Tx) 586 | defer tx.Rollback() 587 | 588 | assertNotEqual(nil, Groups.Delete(tx, gID1), "it should not be possible to delete a singleton group") 589 | assertEqual(nil, Groups.Delete(tx, gID6), "it should be possible to delete a general group") 590 | 591 | fatal0(tx.Commit()) 592 | }) 593 | 594 | t.Run("RolesDelete", func(t *testing.T) { 595 | tx := fatal1(db.Begin()).(*sql.Tx) 596 | defer tx.Rollback() 597 | 598 | assertEqual(nil, Roles.Delete(tx, roleID1)) 599 | 600 | fatal0(tx.Commit()) 601 | 602 | if res = error1(Roles.List(0, 0)); res == nil { 603 | return 604 | } 605 | objs := res.([]*Role) 606 | // There are two pre-defined roles for administrators. 607 | assertEqual(3, len(objs)) 608 | }) 609 | } 610 | 611 | // Tear down. 612 | func TestFlowTearDown(t *testing.T) { 613 | gt = t 614 | 615 | tx := fatal1(db.Begin()).(*sql.Tx) 616 | defer tx.Rollback() 617 | 618 | error1(tx.Exec(`DELETE FROM wf_ac_group_roles`)) 619 | error1(tx.Exec(`DELETE FROM wf_ac_group_hierarchy`)) 620 | error1(tx.Exec(`DELETE FROM wf_access_contexts`)) 621 | 622 | error1(tx.Exec(`DELETE FROM wf_group_users`)) 623 | error1(tx.Exec(`DELETE FROM wf_groups_master`)) 624 | error1(tx.Exec(`DELETE FROM users_master`)) 625 | error1(tx.Exec(`DELETE FROM wf_role_docactions`)) 626 | error1(tx.Exec(`DELETE FROM wf_roles_master WHERE id > 2`)) 627 | 628 | error1(tx.Exec(`DELETE FROM wf_workflow_nodes`)) 629 | error1(tx.Exec(`DELETE FROM wf_workflows`)) 630 | error1(tx.Exec(`DELETE FROM wf_docactions_master`)) 631 | error1(tx.Exec(`DELETE FROM wf_docstates_master WHERE id > 1`)) 632 | error1(tx.Exec(`DELETE FROM wf_doctypes_master`)) 633 | 634 | fatal0(tx.Commit()) 635 | } 636 | -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "fmt" 21 | "math" 22 | "strings" 23 | ) 24 | 25 | // GroupID is the type of unique group identifiers. 26 | type GroupID int64 27 | 28 | // Group represents a specified collection of users. A user belongs 29 | // to zero or more groups. 30 | type Group struct { 31 | ID GroupID `json:"ID"` // Globally-unique ID 32 | Name string `json:"Name"` // Globally-unique name 33 | GroupType string `json:"GroupType"` // Is this a user-specific group? Etc. 34 | } 35 | 36 | // Unexported type, only for convenience methods. 37 | type _Groups struct{} 38 | 39 | // Groups provides a resource-like interface to groups in the system. 40 | var Groups _Groups 41 | 42 | // NewSingleton creates a singleton group associated with the given 43 | // user. The e-mail address of the user is used as the name of the 44 | // group. This serves as the linking identifier. 45 | func (_Groups) NewSingleton(otx *sql.Tx, uid UserID) (GroupID, error) { 46 | var tx *sql.Tx 47 | var err error 48 | if otx == nil { 49 | tx, err = db.Begin() 50 | if err != nil { 51 | return 0, err 52 | } 53 | defer tx.Rollback() 54 | } else { 55 | tx = otx 56 | } 57 | 58 | q := ` 59 | INSERT INTO wf_groups_master(name, group_type) 60 | SELECT u.email, 'S' 61 | FROM wf_users_master u 62 | WHERE u.id = ? 63 | ` 64 | res, err := tx.Exec(q, uid) 65 | if err != nil { 66 | return 0, err 67 | } 68 | var gid int64 69 | gid, err = res.LastInsertId() 70 | if err != nil { 71 | return 0, err 72 | } 73 | 74 | res, err = tx.Exec("INSERT INTO wf_group_users(group_id, user_id) VALUES(?, ?)", gid, uid) 75 | if err != nil { 76 | return 0, err 77 | } 78 | _, err = res.LastInsertId() 79 | if err != nil { 80 | return 0, err 81 | } 82 | 83 | if otx == nil { 84 | err = tx.Commit() 85 | if err != nil { 86 | return 0, err 87 | } 88 | } 89 | 90 | return GroupID(gid), nil 91 | } 92 | 93 | // New creates a new group that can be populated with users later. 94 | func (_Groups) New(otx *sql.Tx, name string, gtype string) (GroupID, error) { 95 | name = strings.TrimSpace(name) 96 | gtype = strings.TrimSpace(gtype) 97 | if name == "" || gtype == "" { 98 | return 0, errors.New("group name and type must not be empty") 99 | } 100 | switch gtype { 101 | case "G": // General 102 | // Nothing to do 103 | 104 | default: 105 | return 0, errors.New("unknown group type") 106 | } 107 | 108 | var tx *sql.Tx 109 | var err error 110 | if otx == nil { 111 | tx, err = db.Begin() 112 | if err != nil { 113 | return 0, err 114 | } 115 | defer tx.Rollback() 116 | } else { 117 | tx = otx 118 | } 119 | 120 | res, err := tx.Exec("INSERT INTO wf_groups_master(name, group_type) VALUES(?, ?)", name, gtype) 121 | if err != nil { 122 | return 0, err 123 | } 124 | var id int64 125 | id, err = res.LastInsertId() 126 | if err != nil { 127 | return 0, err 128 | } 129 | 130 | if otx == nil { 131 | err = tx.Commit() 132 | if err != nil { 133 | return 0, err 134 | } 135 | } 136 | 137 | return GroupID(id), nil 138 | } 139 | 140 | // List answers a subset of the groups, based on the input 141 | // specification. 142 | // 143 | // Result set begins with ID >= `offset`, and has not more than 144 | // `limit` elements. A value of `0` for `offset` fetches from the 145 | // beginning, while a value of `0` for `limit` fetches until the end. 146 | func (_Groups) List(offset, limit int64) ([]*Group, error) { 147 | if offset < 0 || limit < 0 { 148 | return nil, errors.New("offset and limit must be non-negative integers") 149 | } 150 | if limit == 0 { 151 | limit = math.MaxInt64 152 | } 153 | 154 | q := ` 155 | SELECT id, name, group_type 156 | FROM wf_groups_master 157 | ORDER BY id 158 | LIMIT ? OFFSET ? 159 | ` 160 | rows, err := db.Query(q, limit, offset) 161 | if err != nil { 162 | return nil, err 163 | } 164 | defer rows.Close() 165 | 166 | ary := make([]*Group, 0, 10) 167 | for rows.Next() { 168 | var g Group 169 | err = rows.Scan(&g.ID, &g.Name, &g.GroupType) 170 | if err != nil { 171 | return nil, err 172 | } 173 | ary = append(ary, &g) 174 | } 175 | if err = rows.Err(); err != nil { 176 | return nil, err 177 | } 178 | 179 | return ary, nil 180 | } 181 | 182 | // Get initialises the group by reading from database. 183 | func (_Groups) Get(id GroupID) (*Group, error) { 184 | if id <= 0 { 185 | return nil, errors.New("group ID should be a positive integer") 186 | } 187 | 188 | var elem Group 189 | row := db.QueryRow("SELECT id, name, group_type FROM wf_groups_master WHERE id = ?", id) 190 | err := row.Scan(&elem.ID, &elem.Name, &elem.GroupType) 191 | if err != nil { 192 | return nil, err 193 | } 194 | 195 | return &elem, nil 196 | } 197 | 198 | // Rename renames the given group. 199 | func (_Groups) Rename(otx *sql.Tx, id GroupID, name string) error { 200 | name = strings.TrimSpace(name) 201 | if name == "" { 202 | return errors.New("name cannot be empty") 203 | } 204 | 205 | var elem Group 206 | row := db.QueryRow("SELECT id, name, group_type FROM wf_groups_master WHERE id = ?", id) 207 | err := row.Scan(&elem.ID, &elem.Name, &elem.GroupType) 208 | if err != nil { 209 | return err 210 | } 211 | if elem.GroupType == "S" { 212 | return errors.New("cannot rename a singleton group") 213 | } 214 | 215 | var tx *sql.Tx 216 | if otx == nil { 217 | tx, err = db.Begin() 218 | if err != nil { 219 | return err 220 | } 221 | defer tx.Rollback() 222 | } else { 223 | tx = otx 224 | } 225 | 226 | _, err = tx.Exec("UPDATE wf_groups_master SET name = ? WHERE id = ?", name, id) 227 | if err != nil { 228 | return err 229 | } 230 | 231 | if otx == nil { 232 | err = tx.Commit() 233 | if err != nil { 234 | return err 235 | } 236 | } 237 | 238 | return nil 239 | } 240 | 241 | // Delete deletes the given group from the system, if no access 242 | // context is actively using it. 243 | func (_Groups) Delete(otx *sql.Tx, id GroupID) error { 244 | if id <= 0 { 245 | return errors.New("group ID must be a positive integer") 246 | } 247 | 248 | row := db.QueryRow("SELECT group_type FROM wf_groups_master WHERE id = ?", id) 249 | var gtype string 250 | err := row.Scan(>ype) 251 | if err != nil { 252 | return err 253 | } 254 | if gtype == "S" { 255 | return errors.New("singleton groups cannot be deleted") 256 | } 257 | 258 | row = db.QueryRow("SELECT COUNT(*) FROM wf_ac_group_roles WHERE group_id = ?", id) 259 | var n int64 260 | err = row.Scan(&n) 261 | if n > 0 { 262 | return errors.New("group is being used in at least one access context; cannot delete") 263 | } 264 | 265 | var tx *sql.Tx 266 | if otx == nil { 267 | tx, err = db.Begin() 268 | if err != nil { 269 | return err 270 | } 271 | defer tx.Rollback() 272 | } else { 273 | tx = otx 274 | } 275 | 276 | _, err = tx.Exec("DELETE FROM wf_group_users WHERE group_id = ?", id) 277 | if err != nil { 278 | return err 279 | } 280 | res, err := tx.Exec("DELETE FROM wf_groups_master WHERE id = ?", id) 281 | if err != nil { 282 | return err 283 | } 284 | n, err = res.RowsAffected() 285 | if n != 1 { 286 | return fmt.Errorf("expected number of affected rows : 1; actual affected : %d", n) 287 | } 288 | 289 | if otx == nil { 290 | err = tx.Commit() 291 | if err != nil { 292 | return err 293 | } 294 | } 295 | 296 | return nil 297 | } 298 | 299 | // Users answers a list of the given group's users. 300 | func (_Groups) Users(gid GroupID) ([]*User, error) { 301 | q := ` 302 | SELECT um.id, um.first_name, um.last_name, um.email, um.active 303 | FROM wf_users_master um 304 | JOIN wf_group_users gu ON gu.user_id = um.id 305 | WHERE gu.group_id = ? 306 | ` 307 | rows, err := db.Query(q, gid) 308 | if err != nil { 309 | return nil, err 310 | } 311 | defer rows.Close() 312 | 313 | ary := make([]*User, 0, 2) 314 | for rows.Next() { 315 | var elem User 316 | err = rows.Scan(&elem.ID, &elem.FirstName, &elem.LastName, &elem.Email, &elem.Active) 317 | if err != nil { 318 | return nil, err 319 | } 320 | ary = append(ary, &elem) 321 | } 322 | if rows.Err() != nil { 323 | return nil, err 324 | } 325 | 326 | return ary, nil 327 | } 328 | 329 | // HasUser answers `true` if this group includes the given user; 330 | // `false` otherwise. 331 | func (_Groups) HasUser(gid GroupID, uid UserID) (bool, error) { 332 | q := ` 333 | SELECT id FROM wf_group_users 334 | WHERE group_id = ? 335 | AND user_id = ? 336 | ORDER BY id 337 | LIMIT 1 338 | ` 339 | var id int64 340 | row := db.QueryRow(q, gid, uid) 341 | err := row.Scan(&id) 342 | switch { 343 | case err == sql.ErrNoRows: 344 | return false, errors.New("given user is not part of the specified group") 345 | 346 | case err != nil: 347 | return false, err 348 | 349 | default: 350 | return true, nil 351 | } 352 | } 353 | 354 | // SingletonUser answer the user ID of the corresponding user, if this 355 | // group is a singleton group. 356 | func (_Groups) SingletonUser(gid GroupID) (*User, error) { 357 | q := ` 358 | SELECT um.id, um.first_name, um.last_name, um.email, um.active 359 | FROM wf_users_master um 360 | JOIN wf_group_users gus ON gus.user_id = um.id 361 | JOIN wf_groups_master gm ON gus.group_id = gm.id 362 | WHERE gm.id = ? 363 | AND gm.group_type = 'S' 364 | ORDER BY um.id 365 | LIMIT 1 366 | ` 367 | 368 | var elem User 369 | row := db.QueryRow(q, gid) 370 | err := row.Scan(&elem.ID, &elem.FirstName, &elem.LastName, &elem.Email, &elem.Active) 371 | switch { 372 | case err != nil: 373 | return nil, err 374 | 375 | default: 376 | return &elem, nil 377 | } 378 | } 379 | 380 | // AddUser adds the given user as a member of this group. 381 | func (_Groups) AddUser(otx *sql.Tx, gid GroupID, uid UserID) error { 382 | if gid <= 0 || uid <= 0 { 383 | return errors.New("group ID and user ID must be positive integers") 384 | } 385 | 386 | var tx *sql.Tx 387 | var err error 388 | if otx == nil { 389 | tx, err = db.Begin() 390 | if err != nil { 391 | return err 392 | } 393 | defer tx.Rollback() 394 | } else { 395 | tx = otx 396 | } 397 | 398 | var gtype string 399 | row := tx.QueryRow("SELECT group_type FROM wf_groups_master WHERE id = ?", gid) 400 | err = row.Scan(>ype) 401 | if err != nil { 402 | return err 403 | } 404 | if gtype == "S" { 405 | return errors.New("cannot add users to singleton groups") 406 | } 407 | 408 | _, err = tx.Exec("INSERT INTO wf_group_users(group_id, user_id) VALUES(?, ?)", gid, uid) 409 | if err != nil { 410 | return err 411 | } 412 | if otx == nil { 413 | err = tx.Commit() 414 | if err != nil { 415 | return err 416 | } 417 | } 418 | 419 | return nil 420 | } 421 | 422 | // RemoveUser removes the given user from this group, if the user is a 423 | // member of the group. This operation is idempotent. 424 | func (_Groups) RemoveUser(otx *sql.Tx, gid GroupID, uid UserID) error { 425 | if gid <= 0 || uid <= 0 { 426 | return errors.New("group ID and user ID must be positive integers") 427 | } 428 | 429 | var tx *sql.Tx 430 | var err error 431 | if otx == nil { 432 | tx, err = db.Begin() 433 | if err != nil { 434 | return err 435 | } 436 | defer tx.Rollback() 437 | } else { 438 | tx = otx 439 | } 440 | 441 | var gtype string 442 | row := tx.QueryRow("SELECT group_type FROM wf_groups_master WHERE id = ?", gid) 443 | err = row.Scan(>ype) 444 | if err != nil { 445 | return err 446 | } 447 | if gtype == "S" { 448 | return errors.New("cannot remove users from singleton groups") 449 | } 450 | 451 | res, err := tx.Exec("DELETE FROM wf_group_users WHERE group_id = ? AND user_id = ?", gid, uid) 452 | if err != nil { 453 | return err 454 | } 455 | n, err := res.RowsAffected() 456 | if err != nil { 457 | return err 458 | } 459 | if n != 1 { 460 | return fmt.Errorf("expected number of affected rows : 1; actual affected : %d", n) 461 | } 462 | 463 | if otx == nil { 464 | err = tx.Commit() 465 | if err != nil { 466 | return err 467 | } 468 | } 469 | 470 | return nil 471 | } 472 | -------------------------------------------------------------------------------- /mailbox.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "math" 21 | ) 22 | 23 | // Mailbox is the message delivery destination for both action and 24 | // informational messages. 25 | // 26 | // Both users and groups have mailboxes. In all normal cases, a 27 | // message is 'consumed' by the recipient. Messages can be moved into 28 | // and out of mailboxes to facilitate reassignments under specific or 29 | // extraordinary conditions. 30 | type Mailbox struct { 31 | GroupID `json:"GroupID"` // Group (or singleton user) owner of this mailbox 32 | } 33 | 34 | // _Mailboxes provides a resource-like interface to group virtual 35 | // mailboxes. 36 | type _Mailboxes struct { 37 | // Intentionally left blank. 38 | } 39 | 40 | // Mailboxes is the singleton instance of `_Mailboxes`. 41 | var Mailboxes _Mailboxes 42 | 43 | // CountByUser answers the number of messages in the given user's 44 | // virtual mailbox. Specifying `true` for `unread` fetches a count of 45 | // unread messages. 46 | func (_Mailboxes) CountByUser(uid UserID, unread bool) (int64, error) { 47 | if uid <= 0 { 48 | return 0, errors.New("user ID should be a positive integer") 49 | } 50 | 51 | q := ` 52 | SELECT COUNT(id) 53 | FROM wf_mailboxes 54 | WHERE group_id = ( 55 | SELECT gm.id 56 | FROM wf_groups_master gm 57 | JOIN wf_group_users gu ON gu.group_id = gm.id 58 | WHERE gu.user_id = ? 59 | AND gm.group_type = 'S' 60 | ) 61 | ` 62 | if unread { 63 | q += `AND unread = 1` 64 | } 65 | 66 | row := db.QueryRow(q, uid) 67 | var n int64 68 | err := row.Scan(&n) 69 | if err != nil { 70 | return 0, err 71 | } 72 | 73 | return n, nil 74 | } 75 | 76 | // CountByGroup answers the number of messages in the given group's 77 | // virtual mailbox. Specifying `true` for `unread` fetches a count of 78 | // unread messages. 79 | func (_Mailboxes) CountByGroup(gid GroupID, unread bool) (int64, error) { 80 | if gid <= 0 { 81 | return 0, errors.New("group ID should be a positive integer") 82 | } 83 | 84 | q := ` 85 | SELECT COUNT(id) 86 | FROM wf_mailboxes 87 | WHERE group_id = ? 88 | ` 89 | if unread { 90 | q += `AND unread = 1` 91 | } 92 | 93 | row := db.QueryRow(q, gid) 94 | var n int64 95 | err := row.Scan(&n) 96 | if err != nil { 97 | return 0, err 98 | } 99 | 100 | return n, nil 101 | } 102 | 103 | // ListByUser answers a list of the messages in the given user's 104 | // virtual mailbox, as per the given specification. 105 | // 106 | // Result set begins with ID >= `offset`, and has not more than 107 | // `limit` elements. A value of `0` for `offset` fetches from the 108 | // beginning, while a value of `0` for `limit` fetches until the end. 109 | func (_Mailboxes) ListByUser(uid UserID, offset, limit int64, unread bool) ([]*Notification, error) { 110 | if uid <= 0 { 111 | return nil, errors.New("user ID should be a positive integer") 112 | } 113 | if offset < 0 || limit < 0 { 114 | return nil, errors.New("offset and limit must be non-negative integers") 115 | } 116 | if limit == 0 { 117 | limit = math.MaxInt64 118 | } 119 | 120 | q := ` 121 | SELECT mbs.group_id, msgs.id, msgs.doctype_id, dtm.name, msgs.doc_id, msgs.docevent_id, msgs.title, msgs.data, mbs.unread, mbs.ctime 122 | FROM wf_messages msgs 123 | JOIN wf_mailboxes mbs ON mbs.message_id = msgs.id 124 | JOIN wf_doctypes_master dtm ON dtm.id = msgs.doctype_id 125 | WHERE mbs.group_id = ( 126 | SELECT gm.id 127 | FROM wf_groups_master gm 128 | JOIN wf_group_users gu ON gu.group_id = gm.id 129 | WHERE gu.user_id = ? 130 | AND gm.group_type = 'S' 131 | ) 132 | ` 133 | if unread { 134 | q += `AND mbs.unread = 1` 135 | } 136 | q += ` 137 | ORDER BY msgs.id 138 | LIMIT ? OFFSET ? 139 | ` 140 | 141 | rows, err := db.Query(q, uid, limit, offset) 142 | if err != nil { 143 | return nil, err 144 | } 145 | defer rows.Close() 146 | 147 | ary := make([]*Notification, 0, 10) 148 | for rows.Next() { 149 | var elem Notification 150 | err = rows.Scan(&elem.GroupID, &elem.Message.ID, &elem.Message.DocType.ID, 151 | &elem.Message.DocType.Name, &elem.Message.DocID, &elem.Message.Event, 152 | &elem.Message.Title, &elem.Message.Data, &elem.Unread, &elem.Ctime) 153 | if err != nil { 154 | return nil, err 155 | } 156 | ary = append(ary, &elem) 157 | } 158 | if err = rows.Err(); err != nil { 159 | return nil, err 160 | } 161 | 162 | return ary, nil 163 | } 164 | 165 | // ListByGroup answers a list of the messages in the given group's 166 | // virtual mailbox, as per the given specification. 167 | // 168 | // Result set begins with ID >= `offset`, and has not more than 169 | // `limit` elements. A value of `0` for `offset` fetches from the 170 | // beginning, while a value of `0` for `limit` fetches until the end. 171 | func (_Mailboxes) ListByGroup(gid GroupID, offset, limit int64, unread bool) ([]*Notification, error) { 172 | if gid <= 0 { 173 | return nil, errors.New("group ID should be a positive integer") 174 | } 175 | if offset < 0 || limit < 0 { 176 | return nil, errors.New("offset and limit must be non-negative integers") 177 | } 178 | if limit == 0 { 179 | limit = math.MaxInt64 180 | } 181 | 182 | q := ` 183 | SELECT mbs.group_id, msgs.id, msgs.doctype_id, dtm.name, msgs.doc_id, msgs.docevent_id, msgs.title, msgs.data, mbs.unread, mbs.ctime 184 | FROM wf_messages msgs 185 | JOIN wf_mailboxes mbs ON mbs.message_id = msgs.id 186 | JOIN wf_doctypes_master dtm ON dtm.id = msgs.doctype_id 187 | WHERE mbs.group_id = ? 188 | ` 189 | if unread { 190 | q += `AND mbs.unread = 1` 191 | } 192 | q += ` 193 | ORDER BY msgs.id 194 | LIMIT ? OFFSET ? 195 | ` 196 | 197 | rows, err := db.Query(q, gid, limit, offset) 198 | if err != nil { 199 | return nil, err 200 | } 201 | defer rows.Close() 202 | 203 | ary := make([]*Notification, 0, 10) 204 | for rows.Next() { 205 | var elem Notification 206 | err = rows.Scan(&elem.GroupID, &elem.Message.ID, &elem.Message.DocType.ID, 207 | &elem.Message.DocType.Name, &elem.Message.DocID, &elem.Message.Event, 208 | &elem.Message.Title, &elem.Message.Data, &elem.Unread, &elem.Ctime) 209 | if err != nil { 210 | return nil, err 211 | } 212 | ary = append(ary, &elem) 213 | } 214 | if err = rows.Err(); err != nil { 215 | return nil, err 216 | } 217 | 218 | return ary, nil 219 | } 220 | 221 | // GetMessage answers the requested message from the given user's 222 | // virtual mailbox. 223 | func (_Mailboxes) GetMessage(msgID MessageID) (*Notification, error) { 224 | if msgID <= 0 { 225 | return nil, errors.New("message ID should be positive integers") 226 | } 227 | 228 | q := ` 229 | SELECT mbs.group_id, msgs.id, msgs.doctype_id, dtm.name, msgs.doc_id, msgs.docevent_id, msgs.title, msgs.data, mbs.unread, mbs.ctime 230 | FROM wf_messages msgs 231 | JOIN wf_mailboxes mbs ON mbs.message_id = msgs.id 232 | JOIN wf_doctypes_master dtm ON dtm.id = msgs.doctype_id 233 | WHERE mbs.id = ? 234 | ` 235 | row := db.QueryRow(q, msgID) 236 | var elem Notification 237 | err := row.Scan(&elem.GroupID, &elem.Message.ID, &elem.Message.DocType.ID, 238 | &elem.Message.DocType.Name, &elem.Message.DocID, &elem.Message.Event, 239 | &elem.Message.Title, &elem.Message.Data, &elem.Unread, &elem.Ctime) 240 | if err != nil { 241 | return nil, err 242 | } 243 | 244 | return &elem, nil 245 | } 246 | 247 | // ReassignMessage removes the message with the given ID from its 248 | // current mailbox, and delivers it to the given other group's 249 | // mailbox. 250 | func (_Mailboxes) ReassignMessage(otx *sql.Tx, fgid, tgid GroupID, msgID MessageID) error { 251 | if fgid <= 0 || tgid <= 0 || msgID <= 0 { 252 | return errors.New("all identifiers should be positive integers") 253 | } 254 | if fgid == tgid { 255 | return nil 256 | } 257 | 258 | var tx *sql.Tx 259 | var err error 260 | if otx == nil { 261 | tx, err = db.Begin() 262 | if err != nil { 263 | return err 264 | } 265 | defer tx.Rollback() 266 | } else { 267 | tx = otx 268 | } 269 | 270 | q := ` 271 | UPDATE wf_mailboxes SET group_id = ?, unread = 1 272 | WHERE group_id = ? 273 | AND message_id = ? 274 | ` 275 | _, err = tx.Exec(q, tgid, fgid, msgID) 276 | if err != nil { 277 | return err 278 | } 279 | 280 | if otx == nil { 281 | err = tx.Commit() 282 | if err != nil { 283 | return err 284 | } 285 | } 286 | 287 | return nil 288 | } 289 | 290 | // SetStatusByUser sets the `unread` status of the given message as 291 | // per input specification. 292 | func (_Mailboxes) SetStatusByUser(otx *sql.Tx, uid UserID, msgID MessageID, status bool) error { 293 | if uid <= 0 || msgID <= 0 { 294 | return errors.New("all identifiers should be positive integers") 295 | } 296 | 297 | var tx *sql.Tx 298 | var err error 299 | if otx == nil { 300 | tx, err = db.Begin() 301 | if err != nil { 302 | return err 303 | } 304 | defer tx.Rollback() 305 | } else { 306 | tx = otx 307 | } 308 | 309 | q := ` 310 | UPDATE wf_mailboxes SET unread = ? 311 | WHERE group_id = ( 312 | SELECT gm.id 313 | FROM wf_groups_master gm 314 | JOIN wf_group_users gu ON gu.group_id = gm.id 315 | WHERE gu.user_id = ? 316 | AND gm.group_type = 'S' 317 | ) 318 | AND message_id = ? 319 | ` 320 | _, err = tx.Exec(q, status, uid, msgID) 321 | if err != nil { 322 | return err 323 | } 324 | 325 | if otx == nil { 326 | err = tx.Commit() 327 | if err != nil { 328 | return err 329 | } 330 | } 331 | 332 | return nil 333 | } 334 | 335 | // SetStatusByGroup sets the `unread` status of the given message as 336 | // per input specification. 337 | func (_Mailboxes) SetStatusByGroup(otx *sql.Tx, gid GroupID, msgID MessageID, status bool) error { 338 | if gid <= 0 || msgID <= 0 { 339 | return errors.New("all identifiers should be positive integers") 340 | } 341 | 342 | var tx *sql.Tx 343 | var err error 344 | if otx == nil { 345 | tx, err = db.Begin() 346 | if err != nil { 347 | return err 348 | } 349 | defer tx.Rollback() 350 | } else { 351 | tx = otx 352 | } 353 | 354 | q := ` 355 | UPDATE wf_mailboxes SET unread = ? 356 | WHERE group_id = ? 357 | AND message_id = ? 358 | ` 359 | _, err = tx.Exec(q, status, gid, msgID) 360 | if err != nil { 361 | return err 362 | } 363 | 364 | if otx == nil { 365 | err = tx.Commit() 366 | if err != nil { 367 | return err 368 | } 369 | } 370 | 371 | return nil 372 | } 373 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "time" 19 | ) 20 | 21 | // MessageID is the type of unique identifiers of messages. 22 | type MessageID int64 23 | 24 | // Message is the content part of a notification sent by the workflow 25 | // engine to possibly multiple mailboxes. 26 | // 27 | // Messages can be informational or seek action. Each message 28 | // contains a reference to the document that began the current 29 | // workflow, as well as the event that triggered this message. 30 | type Message struct { 31 | ID MessageID `json:"ID"` // Globally-unique identifier of this message 32 | DocType `json:"DocType"` // Document type of the associated document 33 | DocID DocumentID `json:"DocID"` // Document in the workflow 34 | Event DocEventID `json:"DocEvent"` // Event that triggered this message 35 | Title string `json:"Title"` // Subject of this message 36 | Data string `json:"Data"` // Body of this message 37 | } 38 | 39 | // Notification tracks the 'unread' status of a message in a mailbox. 40 | // 41 | // Since a single message can be delivered to multiple mailboxes, the 42 | // 'unread' status cannot be associated with a message. Instead, 43 | // `Notification` is the entity that tracks it per mailbox. 44 | type Notification struct { 45 | GroupID `json:"Group"` // The group whose mailbox this notification is in 46 | Message `json:"Message"` // The underlying message 47 | Unread bool `json:"Unread"` // Status flag reflecting if the message is still not read 48 | Ctime time.Time `json:"Ctime"` // Time when this notification was posted 49 | } 50 | -------------------------------------------------------------------------------- /node.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "log" 21 | ) 22 | 23 | // NodeID is the type of unique identifiers of nodes. 24 | type NodeID int64 25 | 26 | // NodeFunc defines the type of functions that generate notification 27 | // messages in workflows. 28 | // 29 | // These functions are triggered by appropriate nodes, when document 30 | // events are applied to documents to possibly transform them. 31 | // Invocation of a `NodeFunc` should result in a message that can then 32 | // be dispatched to applicable mailboxes. 33 | // 34 | // Error should be returned only when an impossible situation arises, 35 | // and processing needs to abort. Note that returning an error stops 36 | // the workflow. Manual intervention will be needed to move the 37 | // document further. 38 | // 39 | // N. B. NodeFunc instances must be referentially transparent -- 40 | // stateless and not capture their environment in any manner. 41 | // Unexpected bad things could happen otherwise! 42 | type NodeFunc func(*Document, *DocEvent) *Message 43 | 44 | // defNodeFunc prepares a simple message that can be posted to 45 | // applicable mailboces. 46 | func defNodeFunc(d *Document, event *DocEvent) *Message { 47 | return &Message{ 48 | DocType: DocType{ 49 | ID: d.DocType.ID, 50 | }, 51 | DocID: d.ID, 52 | Event: event.ID, 53 | Title: d.Title, 54 | Data: event.Text, 55 | } 56 | } 57 | 58 | // Node represents a specific logical unit of processing and routing 59 | // in a workflow. 60 | type Node struct { 61 | ID NodeID `json:"ID"` // Unique identifier of this node 62 | DocType DocTypeID `json:"DocType"` // Document type which this node's workflow manages 63 | State DocStateID `json:"DocState"` // A document arriving at this node must be in this state 64 | AccCtx AccessContextID `json:"AccessContext,omitempty"` // Specific access context associated with this state, if any 65 | Wflow WorkflowID `json:"Workflow"` // Containing flow of this node 66 | Name string `json:"Name"` // Unique within its workflow 67 | NodeType NodeType `json:"NodeType"` // Topology type of this node 68 | nfunc NodeFunc // Processing function of this node 69 | } 70 | 71 | // Transitions answers the possible document states into which a 72 | // document currently in the given state can transition. 73 | func (n *Node) Transitions() (map[DocActionID]DocStateID, error) { 74 | return DocTypes._Transitions(n.DocType, n.State) 75 | } 76 | 77 | // SetFunc registers the given node function with this node. 78 | // 79 | // If `nil` is given, a default node function is registered instead. 80 | // This default function sets the document title as the message 81 | // subject, and the event's data as the message body. 82 | func (n *Node) SetFunc(fn NodeFunc) error { 83 | if fn == nil { 84 | n.nfunc = defNodeFunc 85 | return nil 86 | } 87 | 88 | n.nfunc = fn 89 | return nil 90 | } 91 | 92 | // Func answers the processing function registered in this node 93 | // definition. 94 | func (n *Node) Func() NodeFunc { 95 | return n.nfunc 96 | } 97 | 98 | // applyEvent checks to see if the given event can be applied 99 | // successfully. Accordingly, it prepares a message by utilising the 100 | // registered node function, and posts it to applicable mailboxes. 101 | func (n *Node) applyEvent(otx *sql.Tx, event *DocEvent, recipients []GroupID) (DocStateID, error) { 102 | ts, err := n.Transitions() 103 | if err != nil { 104 | return 0, err 105 | } 106 | tstate, ok := ts[event.Action] 107 | if !ok { 108 | return 0, ErrWorkflowInvalidAction 109 | } 110 | 111 | // Check document's current state. 112 | doc, err := Documents.Get(otx, event.DocType, event.DocID) 113 | if err != nil { 114 | return 0, err 115 | } 116 | if doc.State.ID != event.State { 117 | return 0, ErrDocEventStateMismatch 118 | } 119 | 120 | // Document has already transitioned. So, we note that the event 121 | // is applied, and return. 122 | // 123 | // N.B. This has implications for `NodeTypeJoinAny` below. Should 124 | // you alter this logic or its position, verify that the 125 | // corresponding logic in the switch below is in coherence. 126 | if doc.State.ID == tstate { 127 | err = n.recordEvent(otx, event, tstate, true) 128 | if err != nil { 129 | return 0, err 130 | } 131 | return tstate, ErrDocEventRedundant 132 | } 133 | 134 | // Transition document state according to the target node type. 135 | 136 | tnode, err := Nodes.GetByState(n.DocType, tstate) 137 | if err != nil { 138 | return 0, err 139 | } 140 | 141 | switch tnode.NodeType { 142 | case NodeTypeJoinAny: 143 | // Multiple 'in's, but any one suffices. 144 | 145 | // We have already checked to see if the document has 146 | // transitioned into the target state. If we have come this 147 | // far, the event can be applied. 148 | fallthrough 149 | 150 | case NodeTypeBegin, NodeTypeEnd, NodeTypeLinear, NodeTypeBranch: 151 | // Any node type having a single 'in'. 152 | 153 | // Update the document to transition the state. 154 | tacid := tnode.AccCtx 155 | if tacid == 0 { 156 | tacid = doc.AccCtx.ID 157 | } 158 | err = Documents.setState(otx, event.DocType, event.DocID, tstate, tacid) 159 | if err != nil { 160 | return 0, err 161 | } 162 | 163 | // Record event application. 164 | err = n.recordEvent(otx, event, tstate, false) 165 | if err != nil { 166 | return 0, err 167 | } 168 | 169 | // Post messages. 170 | recv := make(map[GroupID]struct{}) 171 | for _, gid := range recipients { 172 | recv[gid] = struct{}{} 173 | } 174 | msg := n.nfunc(doc, event) 175 | recv, err = tnode.determineRecipients(otx, recv, doc, event, tacid) 176 | if err != nil { 177 | return 0, err 178 | } 179 | // It is legal to not have any recipients, too. 180 | if len(recv) > 0 { 181 | err = n.postMessage(otx, msg, recv) 182 | if err != nil { 183 | return 0, err 184 | } 185 | } 186 | 187 | case NodeTypeJoinAll: 188 | // Multiple 'in's, and all are required. 189 | 190 | // TODO(js) 191 | 192 | default: 193 | log.Panicf("unknown node type encountered : %s\n", tnode.NodeType) 194 | } 195 | 196 | return tstate, nil 197 | } 198 | 199 | // recordEvent writes a record stating that the given event has 200 | // successfully been applied to effect a document state transition. 201 | func (n *Node) recordEvent(otx *sql.Tx, event *DocEvent, tstate DocStateID, statusOnly bool) error { 202 | if !statusOnly { 203 | q := ` 204 | INSERT INTO wf_docevent_application(doctype_id, doc_id, from_state_id, docevent_id, to_state_id) 205 | VALUES(?, ?, ?, ?, ?) 206 | ` 207 | _, err := otx.Exec(q, event.DocType, event.DocID, event.State, event.ID, tstate) 208 | if err != nil { 209 | return err 210 | } 211 | } 212 | 213 | q := `UPDATE wf_docevents SET status = 'A' WHERE id = ?` 214 | _, err := otx.Exec(q, event.ID) 215 | if err != nil { 216 | return err 217 | } 218 | 219 | return nil 220 | } 221 | 222 | // determineRecipients takes the document type and access context into 223 | // account, and determines the list of groups to which the 224 | // notification should be posted. 225 | func (n *Node) determineRecipients(otx *sql.Tx, recv map[GroupID]struct{}, doc *Document, 226 | event *DocEvent, acid AccessContextID) (map[GroupID]struct{}, error) { 227 | // We have to notify reporting authorities. 228 | q := ` 229 | SELECT reports_to 230 | FROM wf_ac_group_hierarchy 231 | WHERE ac_id = ? 232 | AND group_id = ? 233 | ORDER BY group_id 234 | LIMIT 1 235 | ` 236 | rows, err := otx.Query(q, acid, event.Group) 237 | if err != nil { 238 | return nil, err 239 | } 240 | defer rows.Close() 241 | 242 | for rows.Next() { 243 | var gid int64 244 | err = rows.Scan(&gid) 245 | if err != nil { 246 | return nil, err 247 | } 248 | recv[GroupID(gid)] = struct{}{} 249 | } 250 | if rows.Err() != nil { 251 | return nil, err 252 | } 253 | 254 | // We also notify all participants in the thread. 255 | q2 := ` 256 | SELECT DISTINCT (group_id) 257 | FROM wf_docevents 258 | WHERE doctype_id = ? 259 | AND doc_id = ? 260 | ` 261 | rows2, err := otx.Query(q2, doc.DocType.ID, doc.ID) 262 | if err != nil { 263 | return nil, err 264 | } 265 | defer rows2.Close() 266 | 267 | for rows2.Next() { 268 | var gid int64 269 | err = rows2.Scan(&gid) 270 | if err != nil { 271 | return nil, err 272 | } 273 | recv[GroupID(gid)] = struct{}{} 274 | } 275 | if rows2.Err() != nil { 276 | return nil, err 277 | } 278 | 279 | return recv, nil 280 | } 281 | 282 | // postMessage posts the given message into the mailboxes of the 283 | // specified recipients. 284 | func (n *Node) postMessage(otx *sql.Tx, msg *Message, recv map[GroupID]struct{}) error { 285 | // Record the message. 286 | 287 | q := ` 288 | INSERT INTO wf_messages(doctype_id, doc_id, docevent_id, title, data) 289 | VALUES(?, ?, ?, ?, ?) 290 | ` 291 | res, err := otx.Exec(q, msg.DocType.ID, msg.DocID, msg.Event, msg.Title, msg.Data) 292 | if err != nil { 293 | return err 294 | } 295 | var msgid int64 296 | if msgid, err = res.LastInsertId(); err != nil { 297 | return err 298 | } 299 | 300 | // Post it into applicable mailboxes. 301 | 302 | q = ` 303 | INSERT INTO wf_mailboxes(group_id, message_id, unread, ctime) 304 | VALUES(?, ?, 1, NOW()) 305 | ` 306 | for gid := range recv { 307 | res, err = otx.Exec(q, gid, msgid) 308 | if err != nil { 309 | return err 310 | } 311 | } 312 | 313 | return nil 314 | } 315 | 316 | // Unexported type, only for convenience methods. 317 | type _Nodes struct{} 318 | 319 | // Nodes provides a resource-like interface to the nodes defined in 320 | // this system. 321 | var Nodes _Nodes 322 | 323 | // List answers a list of the nodes comprising the given workflow. 324 | func (_Nodes) List(id WorkflowID) ([]*Node, error) { 325 | q := ` 326 | SELECT id, doctype_id, docstate_id, workflow_id, name, type 327 | FROM wf_workflow_nodes 328 | WHERE workflow_id = ? 329 | ` 330 | rows, err := db.Query(q, id) 331 | if err != nil { 332 | return nil, err 333 | } 334 | defer rows.Close() 335 | 336 | ary := make([]*Node, 0, 5) 337 | for rows.Next() { 338 | var elem Node 339 | err = rows.Scan(&elem.ID, &elem.DocType, &elem.State, &elem.Wflow, &elem.Name, &elem.NodeType) 340 | if err != nil { 341 | return nil, err 342 | } 343 | elem.nfunc = defNodeFunc 344 | ary = append(ary, &elem) 345 | } 346 | if err = rows.Err(); err != nil { 347 | return nil, err 348 | } 349 | 350 | return ary, nil 351 | } 352 | 353 | // Get retrieves the requested node from the database. 354 | func (_Nodes) Get(id NodeID) (*Node, error) { 355 | if id <= 0 { 356 | return nil, errors.New("node ID must be a positive integer") 357 | } 358 | 359 | var elem Node 360 | var acID sql.NullInt64 361 | q := ` 362 | SELECT id, doctype_id, docstate_id, ac_id, workflow_id, name, type 363 | FROM wf_workflow_nodes 364 | WHERE id = ? 365 | ` 366 | row := db.QueryRow(q, id) 367 | err := row.Scan(&elem.ID, &elem.DocType, &elem.State, &acID, &elem.Wflow, &elem.Name, &elem.NodeType) 368 | if err != nil { 369 | return nil, err 370 | } 371 | if acID.Valid { 372 | elem.AccCtx = AccessContextID(acID.Int64) 373 | } 374 | 375 | elem.nfunc = defNodeFunc 376 | return &elem, nil 377 | } 378 | 379 | // GetByState retrieves the requested node from the database, as per 380 | // the document state specification. 381 | func (_Nodes) GetByState(dtype DocTypeID, state DocStateID) (*Node, error) { 382 | var elem Node 383 | var acID sql.NullInt64 384 | q := ` 385 | SELECT id, doctype_id, docstate_id, ac_id, workflow_id, name, type 386 | FROM wf_workflow_nodes 387 | WHERE doctype_id = ? 388 | AND docstate_id = ? 389 | ` 390 | row := db.QueryRow(q, dtype, state) 391 | err := row.Scan(&elem.ID, &elem.DocType, &elem.State, &acID, &elem.Wflow, &elem.Name, &elem.NodeType) 392 | if err != nil { 393 | return nil, err 394 | } 395 | if acID.Valid { 396 | elem.AccCtx = AccessContextID(acID.Int64) 397 | } 398 | 399 | elem.nfunc = defNodeFunc 400 | return &elem, nil 401 | } 402 | -------------------------------------------------------------------------------- /nodetype.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | // NodeType enumerates the possible types of workflow nodes. 18 | type NodeType string 19 | 20 | // The following constants are represented **identically** as part of 21 | // an enumeration in the database. DO NOT ALTER THESE WITHOUT ALSO 22 | // ALTERING THE DATABASE; ELSE DATA COULD GET CORRUPTED! 23 | const ( 24 | // NodeTypeBegin : none incoming, one outgoing 25 | NodeTypeBegin NodeType = "begin" 26 | // NodeTypeEnd : one incoming, none outgoing 27 | NodeTypeEnd = "end" 28 | // NodeTypeLinear : one incoming, one outgoing 29 | NodeTypeLinear = "linear" 30 | // NodeTypeBranch : one incoming, two or more outgoing 31 | NodeTypeBranch = "branch" 32 | // NodeTypeJoinAny : two or more incoming, one outgoing 33 | NodeTypeJoinAny = "joinany" 34 | // NodeTypeJoinAll : two or more incoming, one outgoing 35 | NodeTypeJoinAll = "joinall" 36 | ) 37 | 38 | // IsValidNodeType answers `true` if the given node type is a 39 | // recognised node type in the system. 40 | func IsValidNodeType(ntype string) bool { 41 | nt := NodeType(ntype) 42 | switch nt { 43 | case NodeTypeBegin, NodeTypeEnd, NodeTypeLinear, NodeTypeBranch, NodeTypeJoinAny, NodeTypeJoinAll: 44 | return true 45 | 46 | default: 47 | return false 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /role.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "fmt" 21 | "math" 22 | "strings" 23 | ) 24 | 25 | // RoleID is the type of unique role identifiers. 26 | type RoleID int64 27 | 28 | // Role represents a collection of privileges. 29 | // 30 | // Each group in the system can have one or more roles assigned. 31 | type Role struct { 32 | ID RoleID `json:"ID"` // globally-unique ID of this role 33 | Name string `json:"Name"` // name of this role 34 | } 35 | 36 | // Unexported type, only for convenience methods. 37 | type _Roles struct{} 38 | 39 | // Roles provides a resource-like interface to roles in the system. 40 | var Roles _Roles 41 | 42 | // New creates a role with the given name. 43 | func (_Roles) New(otx *sql.Tx, name string) (RoleID, error) { 44 | name = strings.TrimSpace(name) 45 | if name == "" { 46 | return 0, errors.New("name cannot not be empty") 47 | } 48 | 49 | var tx *sql.Tx 50 | var err error 51 | if otx == nil { 52 | tx, err = db.Begin() 53 | if err != nil { 54 | return 0, err 55 | } 56 | defer tx.Rollback() 57 | } else { 58 | tx = otx 59 | } 60 | 61 | res, err := tx.Exec("INSERT INTO wf_roles_master(name) VALUES(?)", name) 62 | if err != nil { 63 | return 0, err 64 | } 65 | id, err := res.LastInsertId() 66 | if err != nil { 67 | return 0, err 68 | } 69 | 70 | if otx == nil { 71 | err = tx.Commit() 72 | if err != nil { 73 | return 0, err 74 | } 75 | } 76 | 77 | return RoleID(id), nil 78 | } 79 | 80 | // List answers a subset of the roles, based on the input 81 | // specification. 82 | // 83 | // Result set begins with ID >= `offset`, and has not more than 84 | // `limit` elements. A value of `0` for `offset` fetches from the 85 | // beginning, while a value of `0` for `limit` fetches until the end. 86 | func (_Roles) List(offset, limit int64) ([]*Role, error) { 87 | if offset < 0 || limit < 0 { 88 | return nil, errors.New("offset and limit must be non-negative integers") 89 | } 90 | if limit == 0 { 91 | limit = math.MaxInt64 92 | } 93 | 94 | q := ` 95 | SELECT id, name 96 | FROM wf_roles_master 97 | ORDER BY id 98 | LIMIT ? OFFSET ? 99 | ` 100 | rows, err := db.Query(q, limit, offset) 101 | if err != nil { 102 | return nil, err 103 | } 104 | defer rows.Close() 105 | 106 | ary := make([]*Role, 0, 10) 107 | for rows.Next() { 108 | var elem Role 109 | err = rows.Scan(&elem.ID, &elem.Name) 110 | if err != nil { 111 | return nil, err 112 | } 113 | ary = append(ary, &elem) 114 | } 115 | if err = rows.Err(); err != nil { 116 | return nil, err 117 | } 118 | 119 | return ary, nil 120 | } 121 | 122 | // Get loads the role object corresponding to the given role ID from 123 | // the database, and answers that. 124 | func (_Roles) Get(id RoleID) (*Role, error) { 125 | if id <= 0 { 126 | return nil, errors.New("ID must be a positive integer") 127 | } 128 | 129 | var elem Role 130 | row := db.QueryRow("SELECT id, name FROM wf_roles_master WHERE id = ?", id) 131 | err := row.Scan(&elem.ID, &elem.Name) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | return &elem, nil 137 | } 138 | 139 | // GetByName answers the role, if one with the given name is 140 | // registered; `nil` and the error, otherwise. 141 | func (_Roles) GetByName(name string) (*Role, error) { 142 | name = strings.TrimSpace(name) 143 | if name == "" { 144 | return nil, errors.New("role cannot be empty") 145 | } 146 | 147 | var elem Role 148 | row := db.QueryRow("SELECT id, name FROM wf_roles_master WHERE name = ?", name) 149 | err := row.Scan(&elem.ID, &elem.Name) 150 | if err != nil { 151 | return nil, err 152 | } 153 | 154 | return &elem, nil 155 | } 156 | 157 | // Rename renames the given role. 158 | func (_Roles) Rename(otx *sql.Tx, id RoleID, name string) error { 159 | name = strings.TrimSpace(name) 160 | if name == "" { 161 | return errors.New("name cannot be empty") 162 | } 163 | 164 | var tx *sql.Tx 165 | var err error 166 | if otx == nil { 167 | tx, err = db.Begin() 168 | if err != nil { 169 | return err 170 | } 171 | defer tx.Rollback() 172 | } else { 173 | tx = otx 174 | } 175 | 176 | _, err = tx.Exec("UPDATE wf_roles_master SET name = ? WHERE id = ?", name, id) 177 | if err != nil { 178 | return err 179 | } 180 | 181 | if otx == nil { 182 | err = tx.Commit() 183 | if err != nil { 184 | return err 185 | } 186 | } 187 | 188 | return nil 189 | } 190 | 191 | // Delete deletes the given role from the system, if no access context 192 | // is actively using it. 193 | func (_Roles) Delete(otx *sql.Tx, id RoleID) error { 194 | if id <= 0 { 195 | return errors.New("role ID must be a positive integer") 196 | } 197 | 198 | row := db.QueryRow("SELECT COUNT(*) FROM wf_ac_group_roles WHERE role_id = ?", id) 199 | var n int64 200 | err := row.Scan(&n) 201 | if n > 0 { 202 | return errors.New("role is being used in at least one access context; cannot delete") 203 | } 204 | 205 | var tx *sql.Tx 206 | if otx == nil { 207 | tx, err = db.Begin() 208 | if err != nil { 209 | return err 210 | } 211 | defer tx.Rollback() 212 | } else { 213 | tx = otx 214 | } 215 | 216 | _, err = tx.Exec("DELETE FROM wf_role_docactions WHERE role_id = ?", id) 217 | if err != nil { 218 | return err 219 | } 220 | res, err := tx.Exec("DELETE FROM wf_roles_master WHERE id = ?", id) 221 | if err != nil { 222 | return err 223 | } 224 | n, err = res.RowsAffected() 225 | if n != 1 { 226 | return fmt.Errorf("expected number of affected rows : 1; actual affected : %d", n) 227 | } 228 | 229 | if otx == nil { 230 | err = tx.Commit() 231 | if err != nil { 232 | return err 233 | } 234 | } 235 | 236 | return nil 237 | } 238 | 239 | // AddPermissions adds the given actions to this role, for the given 240 | // document type. 241 | func (_Roles) AddPermissions(otx *sql.Tx, rid RoleID, dtype DocTypeID, actions []DocActionID) error { 242 | var tx *sql.Tx 243 | var err error 244 | if otx == nil { 245 | tx, err = db.Begin() 246 | if err != nil { 247 | return err 248 | } 249 | defer tx.Rollback() 250 | } else { 251 | tx = otx 252 | } 253 | 254 | q := ` 255 | INSERT INTO wf_role_docactions(role_id, doctype_id, docaction_id) 256 | VALUES(?, ?, ?) 257 | ` 258 | for _, action := range actions { 259 | _, err = tx.Exec(q, rid, dtype, action) 260 | if err != nil { 261 | return err 262 | } 263 | } 264 | 265 | if otx == nil { 266 | err = tx.Commit() 267 | if err != nil { 268 | return err 269 | } 270 | } 271 | return nil 272 | } 273 | 274 | // RemovePermissions removes the given actions from this role, for the 275 | // given document type. 276 | func (_Roles) RemovePermissions(otx *sql.Tx, rid RoleID, dtype DocTypeID, actions []DocActionID) error { 277 | var tx *sql.Tx 278 | var err error 279 | if otx == nil { 280 | tx, err = db.Begin() 281 | if err != nil { 282 | return err 283 | } 284 | defer tx.Rollback() 285 | } else { 286 | tx = otx 287 | } 288 | 289 | q := ` 290 | DELETE FROM wf_role_docactions 291 | WHERE role_id = ? 292 | AND doctype_id = ? 293 | AND docaction_id = ? 294 | ` 295 | for _, action := range actions { 296 | _, err = tx.Exec(q, rid, dtype, action) 297 | if err != nil { 298 | return err 299 | } 300 | } 301 | 302 | if otx == nil { 303 | err = tx.Commit() 304 | if err != nil { 305 | return err 306 | } 307 | } 308 | return nil 309 | } 310 | 311 | // Permissions answers the current set of permissions this role has. 312 | // It answers `nil` in case the given document type does not have any 313 | // permissions set in this role. 314 | func (_Roles) Permissions(rid RoleID) (map[string]struct { 315 | DocTypeID DocTypeID 316 | Actions []*DocAction 317 | }, error) { 318 | q := ` 319 | SELECT dtm.id, dtm.name, dam.id, dam.name, dam.reconfirm 320 | FROM wf_doctypes_master dtm 321 | JOIN wf_role_docactions rdas ON dtm.id = rdas.doctype_id 322 | JOIN wf_docactions_master dam ON dam.id = rdas.docaction_id 323 | WHERE rdas.role_id = ? 324 | ` 325 | rows, err := db.Query(q, rid) 326 | if err != nil { 327 | return nil, err 328 | } 329 | defer rows.Close() 330 | 331 | das := make(map[string]struct { 332 | DocTypeID DocTypeID 333 | Actions []*DocAction 334 | }) 335 | for rows.Next() { 336 | var dt DocType 337 | var da DocAction 338 | err = rows.Scan(&dt.ID, &dt.Name, &da.ID, &da.Name, &da.Reconfirm) 339 | if err != nil { 340 | return nil, err 341 | } 342 | st, ok := das[dt.Name] 343 | if !ok { 344 | st.DocTypeID = dt.ID 345 | st.Actions = make([]*DocAction, 0, 1) 346 | } 347 | st.Actions = append(st.Actions, &da) 348 | das[dt.Name] = st 349 | } 350 | if err = rows.Err(); err != nil { 351 | return nil, err 352 | } 353 | 354 | return das, nil 355 | } 356 | 357 | // HasPermission answers `true` if this role has the queried 358 | // permission for the given document type. 359 | func (_Roles) HasPermission(rid RoleID, dtype DocTypeID, action DocActionID) (bool, error) { 360 | q := ` 361 | SELECT rdas.id FROM wf_role_docactions rdas 362 | JOIN wf_doctypes_master dtm ON rdas.doctype_id = dtm.id 363 | JOIN wf_docactions_master dam ON rdas.docaction_id = dam.id 364 | WHERE rdas.role_id = ? 365 | AND dtm.id = ? 366 | AND dam.id = ? 367 | ORDER BY rdas.id 368 | LIMIT 1 369 | ` 370 | row := db.QueryRow(q, rid, dtype, action) 371 | var n int64 372 | err := row.Scan(&n) 373 | if err != nil { 374 | switch err { 375 | case sql.ErrNoRows: 376 | return false, nil 377 | 378 | default: 379 | return false, err 380 | } 381 | } 382 | 383 | return true, nil 384 | } 385 | -------------------------------------------------------------------------------- /sql/setup_blob_dirs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | chars=$(echo "0 1 2 3 4 5 6 7 8 9 a b c d e f" | cut -d' ' -f 1-) 4 | 5 | if [ "$1" = "" ]; then 6 | mkdir ./data 7 | basedir="./data" 8 | else 9 | basedir=$1 10 | fi 11 | 12 | for c1 in ${chars[@]}; do 13 | for c2 in ${chars[@]}; do 14 | mkdir $basedir/$c1$c2 15 | done 16 | done 17 | -------------------------------------------------------------------------------- /sql/setup_db.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # User as whom to create the database. 4 | user="travis" 5 | 6 | # Create database if requested. This created database is a test 7 | # database named `flow`. 8 | if [ "$1" = "" ]; then 9 | echo Specify either '-t' for test database, or an existing database name 10 | exit 1 11 | elif [ "$1" = "-t" ]; then 12 | mysql -u $user < ./sql/wf_database.sql > err.log 2>&1 13 | db="flow" 14 | else 15 | db=$1 16 | fi 17 | 18 | # Create document-related masters. 19 | mysql -u $user $db < ./sql/wf_doctypes_master.sql >> err.log 2>&1 20 | mysql -u $user $db < ./sql/wf_docstates_master.sql >> err.log 2>&1 21 | mysql -u $user $db < ./sql/wf_docactions_master.sql >> err.log 2>&1 22 | 23 | # Create a local users master, if in test mode. 24 | if [ "$1" = "-t" ]; then 25 | mysql -u $user $db < ./sql/users_master.sql >> err.log 2>&1 26 | fi 27 | 28 | # Users, groups, roles and permissions. 29 | mysql -u $user $db < ./sql/wf_users_master.sql >> err.log 2>&1 30 | mysql -u $user $db < ./sql/wf_groups_master.sql >> err.log 2>&1 31 | mysql -u $user $db < ./sql/wf_roles_master.sql >> err.log 2>&1 32 | mysql -u $user $db < ./sql/wf_group_users.sql >> err.log 2>&1 33 | mysql -u $user $db < ./sql/wf_role_docactions.sql >> err.log 2>&1 34 | mysql -u $user $db < ./sql/wf_access_contexts.sql >> err.log 2>&1 35 | mysql -u $user $db < ./sql/wf_ac_group_roles.sql >> err.log 2>&1 36 | mysql -u $user $db < ./sql/wf_ac_group_hierarchy.sql >> err.log 2>&1 37 | mysql -u $user $db < ./sql/wf_ac_perms_v.sql >> err.log 2>&1 38 | 39 | # Workflow related. 40 | mysql -u $user $db < ./sql/wf_documents.sql >> err.log 2>&1 41 | mysql -u $user $db < ./sql/wf_docstate_transitions.sql >> err.log 2>&1 42 | mysql -u $user $db < ./sql/wf_docevents.sql >> err.log 2>&1 43 | mysql -u $user $db < ./sql/wf_docevent_application.sql >> err.log 2>&1 44 | mysql -u $user $db < ./sql/wf_workflows.sql >> err.log 2>&1 45 | mysql -u $user $db < ./sql/wf_workflow_nodes.sql >> err.log 2>&1 46 | mysql -u $user $db < ./sql/wf_messages.sql >> err.log 2>&1 47 | mysql -u $user $db < ./sql/wf_mailboxes.sql >> err.log 2>&1 48 | -------------------------------------------------------------------------------- /sql/users_master.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users_master; 2 | 3 | -- 4 | 5 | CREATE TABLE users_master ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | first_name VARCHAR(30) NOT NULL, 8 | last_name VARCHAR(30) NOT NULL, 9 | email VARCHAR(100) NOT NULL, 10 | active TINYINT(1) NOT NULL, 11 | PRIMARY KEY (id), 12 | UNIQUE (email) 13 | ); 14 | -------------------------------------------------------------------------------- /sql/wf_ac_group_hierarchy.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_ac_group_hierarchy; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_ac_group_hierarchy ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | ac_id INT NOT NULL, 8 | group_id INT NOT NULL, 9 | reports_to INT NOT NULL, 10 | PRIMARY KEY (id), 11 | FOREIGN KEY (ac_id) REFERENCES wf_access_contexts(id), 12 | FOREIGN KEY (group_id) REFERENCES wf_groups_master(id), 13 | FOREIGN KEY (reports_to) REFERENCES wf_groups_master(id), 14 | UNIQUE (ac_id, group_id) 15 | ); 16 | -------------------------------------------------------------------------------- /sql/wf_ac_group_roles.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_ac_group_roles; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_ac_group_roles ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | ac_id INT NOT NULL, 8 | group_id INT NOT NULL, 9 | role_id INT NOT NULL, 10 | PRIMARY KEY (id), 11 | FOREIGN KEY (ac_id) REFERENCES wf_access_contexts(id), 12 | FOREIGN KEY (group_id) REFERENCES wf_groups_master(id), 13 | FOREIGN KEY (role_id) REFERENCES wf_roles_master(id) 14 | ); 15 | -------------------------------------------------------------------------------- /sql/wf_ac_perms_v.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE VIEW wf_ac_perms_v AS 2 | SELECT ac_grs.ac_id, ac_grs.group_id, gu.user_id, ac_grs.role_id, rdas.doctype_id, rdas.docaction_id 3 | FROM wf_ac_group_roles ac_grs 4 | JOIN wf_group_users gu ON ac_grs.group_id = gu.group_id 5 | JOIN wf_role_docactions rdas ON ac_grs.role_id = rdas.role_id; 6 | -------------------------------------------------------------------------------- /sql/wf_access_contexts.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_access_contexts; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_access_contexts ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | name VARCHAR(100) NOT NULL, 8 | active TINYINT(1) NOT NULL, 9 | PRIMARY KEY (id), 10 | UNIQUE (name) 11 | ); 12 | -------------------------------------------------------------------------------- /sql/wf_database.sql: -------------------------------------------------------------------------------- 1 | -- This is only for testing purposes. In production mode, the 2 | -- consuming application is expected to create the database. 3 | -- 4 | -- N.B. The character set must be specified as 'utf8mb4' for proper 5 | -- UTF-8 handling. A simple 'utf8' does not suffice. 6 | 7 | DROP DATABASE IF EXISTS flow; 8 | 9 | -- 10 | 11 | CREATE DATABASE flow 12 | CHARACTER SET = 'utf8mb4' 13 | COLLATE = 'utf8mb4_unicode_ci'; 14 | 15 | SET collation_server = 'utf8mb4_unicode_ci'; 16 | SET collation_connection = 'utf8mb4_unicode_ci'; 17 | -------------------------------------------------------------------------------- /sql/wf_docactions_master.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_docactions_master; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_docactions_master ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | name VARCHAR(100) NOT NULL, 8 | reconfirm TINYINT(1) NOT NULL, 9 | PRIMARY KEY (id), 10 | UNIQUE (name) 11 | ); 12 | -------------------------------------------------------------------------------- /sql/wf_docevent_application.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_docevent_application; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_docevent_application ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | doctype_id INT NOT NULL, 8 | doc_id INT NOT NULL, 9 | from_state_id INT NOT NULL, 10 | docevent_id INT NOT NULL, 11 | to_state_id INT NOT NULL, 12 | PRIMARY KEY (id), 13 | FOREIGN KEY (doctype_id) REFERENCES wf_doctypes_master(id), 14 | FOREIGN KEY (from_state_id) REFERENCES wf_docstates_master(id), 15 | FOREIGN KEY (docevent_id) REFERENCES wf_docevents(id), 16 | FOREIGN KEY (to_state_id) REFERENCES wf_docstates_master(id) 17 | ); 18 | -------------------------------------------------------------------------------- /sql/wf_docevents.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_docevents; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_docevents ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | doctype_id INT NOT NULL, 8 | doc_id INT NOT NULL, 9 | docstate_id INT NOT NULL, 10 | docaction_id INT NOT NULL, 11 | group_id INT NOT NULL, 12 | data TEXT, 13 | ctime TIMESTAMP NOT NULL, 14 | status ENUM('A', 'P') NOT NULL, 15 | PRIMARY KEY (id), 16 | FOREIGN KEY (doctype_id) REFERENCES wf_doctypes_master(id), 17 | FOREIGN KEY (docstate_id) REFERENCES wf_docstates_master(id), 18 | FOREIGN KEY (docaction_id) REFERENCES wf_docactions_master(id), 19 | FOREIGN KEY (group_id) REFERENCES wf_groups_master(id) 20 | ); 21 | -------------------------------------------------------------------------------- /sql/wf_docstate_transitions.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_docstate_transitions; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_docstate_transitions ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | doctype_id INT NOT NULL, 8 | from_state_id INT NOT NULL, 9 | docaction_id INT NOT NULL, 10 | to_state_id INT NOT NULL, 11 | PRIMARY KEY (id), 12 | FOREIGN KEY (doctype_id) REFERENCES wf_doctypes_master(id), 13 | FOREIGN KEY (from_state_id) REFERENCES wf_docstates_master(id), 14 | FOREIGN KEY (docaction_id) REFERENCES wf_docactions_master(id), 15 | FOREIGN KEY (to_state_id) REFERENCES wf_docstates_master(id), 16 | UNIQUE (doctype_id, from_state_id, docaction_id, to_state_id) 17 | ); 18 | -------------------------------------------------------------------------------- /sql/wf_docstates_master.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_docstates_master; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_docstates_master ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | name VARCHAR(100) NOT NULL, 8 | PRIMARY KEY (id), 9 | UNIQUE (name) 10 | ); 11 | 12 | -- 13 | 14 | -- This reserved state has ID `1`. This is used as the only legal 15 | -- state for children documents. 16 | INSERT INTO wf_docstates_master(name) 17 | VALUES('__RESERVED_CHILD_STATE__'); 18 | -------------------------------------------------------------------------------- /sql/wf_doctypes_master.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_doctypes_master; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_doctypes_master ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | name VARCHAR(100) NOT NULL, 8 | PRIMARY KEY (id), 9 | UNIQUE (name) 10 | ); 11 | -------------------------------------------------------------------------------- /sql/wf_documents.sql: -------------------------------------------------------------------------------- 1 | -- CREATE TABLE wf_documents_ ( 2 | -- id INT NOT NULL AUTO_INCREMENT, 3 | -- path VARCHAR(1000) NOT NULL, 4 | -- ac_id INT NOT NULL, 5 | -- docstate_id INT NOT NULL, 6 | -- group_id INT NOT NULL, 7 | -- ctime TIMESTAMP NOT NULL, 8 | -- title VARCHAR(250) NULL, 9 | -- data TEXT NOT NULL, 10 | -- PRIMARY KEY (id), 11 | -- FOREIGN KEY (ac_id) REFERENCES wf_access_contexts(id), 12 | -- FOREIGN KEY (docstate_id) REFERENCES wf_docstates_master(id), 13 | -- FOREIGN KEY (group_id) REFERENCES wf_groups_master(id) 14 | -- ); 15 | 16 | -- 17 | 18 | DROP TABLE IF EXISTS wf_document_children; 19 | 20 | CREATE TABLE wf_document_children ( 21 | id INT NOT NULL AUTO_INCREMENT, 22 | parent_doctype_id INT NOT NULL, 23 | parent_id INT NOT NULL, 24 | child_doctype_id INT NOT NULL, 25 | child_id INT NOT NULL, 26 | PRIMARY KEY (id), 27 | FOREIGN KEY (parent_doctype_id) REFERENCES wf_doctypes_master(id), 28 | FOREIGN KEY (child_doctype_id) REFERENCES wf_doctypes_master(id), 29 | UNIQUE (parent_doctype_id, parent_id, child_doctype_id, child_id) 30 | ); 31 | 32 | -- 33 | 34 | DROP TABLE IF EXISTS wf_document_blobs; 35 | 36 | CREATE TABLE wf_document_blobs ( 37 | id INT NOT NULL AUTO_INCREMENT, 38 | doctype_id INT NOT NULL, 39 | doc_id INT NOT NULL, 40 | sha1sum CHAR(40) NOT NULL, 41 | name TEXT NOT NULL, 42 | path TEXT NOT NULL, 43 | PRIMARY KEY (id), 44 | FOREIGN KEY (doctype_id) REFERENCES wf_doctypes_master(id), 45 | UNIQUE (doctype_id, doc_id, sha1sum) 46 | ); 47 | 48 | -- 49 | 50 | DROP TABLE IF EXISTS wf_document_tags; 51 | 52 | CREATE TABLE wf_document_tags ( 53 | id INT NOT NULL AUTO_INCREMENT, 54 | doctype_id INT NOT NULL, 55 | doc_id INT NOT NULL, 56 | tag VARCHAR(50) NOT NULL, 57 | PRIMARY KEY (id), 58 | FOREIGN KEY (doctype_id) REFERENCES wf_doctypes_master(id), 59 | UNIQUE (doctype_id, doc_id, tag) 60 | ); 61 | -------------------------------------------------------------------------------- /sql/wf_group_users.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_group_users; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_group_users ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | group_id INT NOT NULL, 8 | user_id INT NOT NULL, 9 | PRIMARY KEY (id), 10 | FOREIGN KEY (group_id) REFERENCES wf_groups_master(id), 11 | UNIQUE (group_id, user_id) 12 | ); 13 | -------------------------------------------------------------------------------- /sql/wf_groups_master.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_groups_master; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_groups_master ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | name VARCHAR(100) NOT NULL, 8 | group_type ENUM('G', 'S'), 9 | PRIMARY KEY (id), 10 | UNIQUE (name) 11 | ); 12 | -------------------------------------------------------------------------------- /sql/wf_mailboxes.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_mailboxes; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_mailboxes ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | group_id INT NOT NULL, 8 | message_id INT NOT NULL, 9 | unread TINYINT(1) NOT NULL, 10 | ctime TIMESTAMP NOT NULL, 11 | PRIMARY KEY (id), 12 | FOREIGN KEY (group_id) REFERENCES wf_groups_master(id), 13 | FOREIGN KEY (message_id) REFERENCES wf_messages(id), 14 | UNIQUE (group_id, message_id) 15 | ); 16 | -------------------------------------------------------------------------------- /sql/wf_messages.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_messages; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_messages ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | doctype_id INT NOT NULL, 8 | doc_id INT NOT NULL, 9 | docevent_id INT NOT NULL, 10 | title VARCHAR(250) NOT NULL, 11 | data TEXT NOT NULL, 12 | PRIMARY KEY (id), 13 | FOREIGN KEY (doctype_id) REFERENCES wf_doctypes_master(id), 14 | FOREIGN KEY (docevent_id) REFERENCES wf_docevents(id), 15 | UNIQUE (doctype_id, doc_id, docevent_id) 16 | ); 17 | -------------------------------------------------------------------------------- /sql/wf_role_docactions.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_role_docactions; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_role_docactions ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | role_id INT NOT NULL, 8 | doctype_id INT NOT NULL, 9 | docaction_id INT NOT NULL, 10 | PRIMARY KEY (id), 11 | FOREIGN KEY (role_id) REFERENCES wf_roles_master(id), 12 | FOREIGN KEY (doctype_id) REFERENCES wf_doctypes_master(id), 13 | FOREIGN KEY (docaction_id) REFERENCES wf_docactions_master(id), 14 | UNIQUE (role_id, doctype_id, docaction_id) 15 | ); 16 | -------------------------------------------------------------------------------- /sql/wf_roles_master.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_roles_master; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_roles_master ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | name VARCHAR(50) NOT NULL, 8 | PRIMARY KEY (id), 9 | UNIQUE (name) 10 | ); 11 | 12 | -- 13 | 14 | -- This reserved role is for users who should administer `flow` 15 | -- itself. That includes (but is not limited to) definition and 16 | -- management of document types, their workflows, roles and groups. 17 | INSERT INTO wf_roles_master(name) 18 | VALUES('SUPER_ADMIN'); 19 | 20 | -- This reserved role is for users who assume apex positions in 21 | -- day-to-day operations. This role can be used to administer the 22 | -- workflow operations within access contexts, when needed. 23 | INSERT INTO wf_roles_master(name) 24 | VALUES('ADMIN'); 25 | -------------------------------------------------------------------------------- /sql/wf_users_master.sql: -------------------------------------------------------------------------------- 1 | -- This assumes the existence of a master table for users, by name 2 | -- `users_master`. It also assumes availability of the specified 3 | -- columns in that master table. This may need to be edited 4 | -- appropriately, depending on your application and database design. 5 | 6 | CREATE OR REPLACE VIEW wf_users_master AS 7 | SELECT id, first_name, last_name, email, active 8 | FROM users_master; 9 | -------------------------------------------------------------------------------- /sql/wf_workflow_nodes.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_workflow_nodes; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_workflow_nodes ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | doctype_id INT NOT NULL, 8 | docstate_id INT NOT NULL, 9 | ac_id INT, 10 | workflow_id INT NOT NULL, 11 | name VARCHAR(100) NOT NULL, 12 | type ENUM('begin', 'end', 'linear', 'branch', 'joinany', 'joinall') NOT NULL, 13 | PRIMARY KEY (id), 14 | FOREIGN KEY (doctype_id) REFERENCES wf_doctypes_master(id), 15 | FOREIGN KEY (docstate_id) REFERENCES wf_docstates_master(id), 16 | FOREIGN KEY (ac_id) REFERENCES wf_access_contexts(id), 17 | FOREIGN KEY (workflow_id) REFERENCES wf_workflows(id), 18 | UNIQUE (doctype_id, docstate_id), 19 | UNIQUE (workflow_id, name) 20 | ); 21 | -------------------------------------------------------------------------------- /sql/wf_workflows.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS wf_workflows; 2 | 3 | -- 4 | 5 | CREATE TABLE wf_workflows ( 6 | id INT NOT NULL AUTO_INCREMENT, 7 | name VARCHAR(100) NOT NULL, 8 | doctype_id INT NOT NULL, 9 | docstate_id INT NOT NULL, 10 | active TINYINT(1) NOT NULL, 11 | PRIMARY KEY (id), 12 | FOREIGN KEY (doctype_id) REFERENCES wf_doctypes_master(id), 13 | FOREIGN KEY (docstate_id) REFERENCES wf_docstates_master(id), 14 | UNIQUE (name), 15 | UNIQUE (doctype_id) 16 | ); 17 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "math" 21 | "strings" 22 | ) 23 | 24 | // UserID is the type of unique user identifiers. 25 | type UserID int64 26 | 27 | // User represents any kind of a user invoking or otherwise 28 | // participating in a defined workflow in the system. 29 | // 30 | // User details are expected to be provided by an external identity 31 | // provider application or directory. `flow` neither defines nor 32 | // manages users. 33 | type User struct { 34 | ID UserID `json:"ID"` // Must be globally-unique 35 | FirstName string `json:"FirstName"` // For display purposes only 36 | LastName string `json:"LastName"` // For display purposes only 37 | Email string `json:"Email"` // E-mail address of this user 38 | Active bool `json:"Active,omitempty"` // Is this user account active? 39 | } 40 | 41 | // Unexported type, only for convenience methods. 42 | type _Users struct{} 43 | 44 | // Users provides a resource-like interface to users in the system. 45 | var Users _Users 46 | 47 | // List answers a subset of the users, based on the input 48 | // specification. 49 | // 50 | // Result set begins with ID >= `offset`, and has not more than 51 | // `limit` elements. A value of `0` for `offset` fetches from the 52 | // beginning, while a value of `0` for `limit` fetches until the end. 53 | func (_Users) List(prefix string, offset, limit int64) ([]*User, error) { 54 | if offset < 0 || limit < 0 { 55 | return nil, errors.New("offset and limit must be non-negative integers") 56 | } 57 | if limit == 0 { 58 | limit = math.MaxInt64 59 | } 60 | 61 | var q string 62 | var rows *sql.Rows 63 | var err error 64 | 65 | prefix = strings.TrimSpace(prefix) 66 | if prefix == "" { 67 | q = ` 68 | SELECT id, first_name, last_name, email, active 69 | FROM wf_users_master 70 | ORDER BY id 71 | LIMIT ? OFFSET ? 72 | ` 73 | rows, err = db.Query(q, limit, offset) 74 | } else { 75 | q = ` 76 | SELECT id, first_name, last_name, email, active 77 | FROM wf_users_master 78 | WHERE first_name LIKE ? 79 | UNION 80 | SELECT id, first_name, last_name, email, active 81 | FROM wf_users_master 82 | WHERE last_name LIKE ? 83 | ORDER BY id 84 | LIMIT ? OFFSET ? 85 | ` 86 | rows, err = db.Query(q, prefix+"%", prefix+"%", limit, offset) 87 | } 88 | if err != nil { 89 | return nil, err 90 | } 91 | defer rows.Close() 92 | 93 | ary := make([]*User, 0, 10) 94 | for rows.Next() { 95 | var elem User 96 | err = rows.Scan(&elem.ID, &elem.FirstName, &elem.LastName, &elem.Email, &elem.Active) 97 | if err != nil { 98 | return nil, err 99 | } 100 | ary = append(ary, &elem) 101 | } 102 | if err = rows.Err(); err != nil { 103 | return nil, err 104 | } 105 | 106 | return ary, nil 107 | } 108 | 109 | // Get instantiates a user instance by reading the database. 110 | func (_Users) Get(uid UserID) (*User, error) { 111 | if uid <= 0 { 112 | return nil, errors.New("user ID should be a positive integer") 113 | } 114 | 115 | var elem User 116 | row := db.QueryRow("SELECT id, first_name, last_name, email, active FROM wf_users_master WHERE id = ?", uid) 117 | err := row.Scan(&elem.ID, &elem.FirstName, &elem.LastName, &elem.Email, &elem.Active) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | return &elem, nil 123 | } 124 | 125 | // GetByEmail retrieves user information from the database, by looking 126 | // up the given e-mail address. 127 | func (_Users) GetByEmail(email string) (*User, error) { 128 | email = strings.TrimSpace(email) 129 | if email == "" { 130 | return nil, errors.New("e-mail address should be non-empty") 131 | } 132 | 133 | var elem User 134 | row := db.QueryRow("SELECT id, first_name, last_name, email, active FROM wf_users_master WHERE email = ?", email) 135 | err := row.Scan(&elem.ID, &elem.FirstName, &elem.LastName, &elem.Email, &elem.Active) 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | return &elem, nil 141 | } 142 | 143 | // IsActive answers `true` if the given user's account is enabled. 144 | func (_Users) IsActive(uid UserID) (bool, error) { 145 | row := db.QueryRow("SELECT active FROM wf_users_master WHERE id = ?", uid) 146 | var active bool 147 | err := row.Scan(&active) 148 | if err != nil { 149 | return false, err 150 | } 151 | 152 | return active, nil 153 | } 154 | 155 | // GroupsOf answers a list of groups that the given user is a member 156 | // of. 157 | func (_Users) GroupsOf(uid UserID) ([]*Group, error) { 158 | q := ` 159 | SELECT gm.id, gm.name, gm.group_type 160 | FROM wf_groups_master gm 161 | JOIN wf_group_users gus ON gus.group_id = gm.id 162 | JOIN wf_users_master um ON um.id = gus.user_id 163 | WHERE um.id = ? 164 | ` 165 | rows, err := db.Query(q, uid) 166 | if err != nil { 167 | return nil, err 168 | } 169 | defer rows.Close() 170 | 171 | ary := make([]*Group, 0, 2) 172 | for rows.Next() { 173 | var elem Group 174 | err = rows.Scan(&elem.ID, &elem.Name, &elem.GroupType) 175 | if err != nil { 176 | return nil, err 177 | } 178 | ary = append(ary, &elem) 179 | } 180 | err = rows.Err() 181 | if err != nil { 182 | return nil, err 183 | } 184 | 185 | return ary, nil 186 | } 187 | 188 | // SingletonGroupOf answers the ID of the given user's singleton 189 | // group. 190 | func (_Users) SingletonGroupOf(uid UserID) (*Group, error) { 191 | q := ` 192 | SELECT gm.id, gm.name, gm.group_type 193 | FROM wf_groups_master gm 194 | JOIN wf_group_users gu ON gu.group_id = gm.id 195 | WHERE gu.user_id = ? 196 | AND gm.group_type = 'S' 197 | ` 198 | var elem Group 199 | row := db.QueryRow(q, uid) 200 | err := row.Scan(&elem.ID, &elem.Name, &elem.GroupType) 201 | if err != nil { 202 | return nil, err 203 | } 204 | 205 | return &elem, nil 206 | } 207 | -------------------------------------------------------------------------------- /workflow.go: -------------------------------------------------------------------------------- 1 | // (c) Copyright 2015-2017 JONNALAGADDA Srinivas 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package flow 16 | 17 | import ( 18 | "database/sql" 19 | "errors" 20 | "math" 21 | "strings" 22 | ) 23 | 24 | // WorkflowID is the type of unique workflow identifiers. 25 | type WorkflowID int64 26 | 27 | // Workflow represents the entire life cycle of a single document. 28 | // 29 | // A workflow begins with the creation of a document, and drives its 30 | // life cycle through a sequence of responses to user actions or other 31 | // system events. 32 | // 33 | // The engine in `flow` is visible primarily through workflows, 34 | // documents and their behaviour. 35 | // 36 | // Currently, the topology of workflows is a graph, and is determined 37 | // by the node definitions herein. 38 | // 39 | // N.B. It is highly recommended, but not necessary, that workflow 40 | // names be defined in a system of hierarchical namespaces. 41 | type Workflow struct { 42 | ID WorkflowID `json:"ID,omitempty"` // Globally-unique identifier of this workflow 43 | Name string `json:"Name,omitempty"` // Globally-unique name of this workflow 44 | DocType DocType `json:"DocType"` // Document type of which this workflow defines the life cycle 45 | BeginState DocState `json:"BeginState"` // Where this flow begins 46 | Active bool `json:"Active,omitempty"` // Is this workflow enabled? 47 | } 48 | 49 | // ApplyEvent takes an input user action or a system event, and 50 | // applies its document action to the given document. This results in 51 | // a possibly new document state. This method also prepares a message 52 | // that is posted to applicable mailboxes. 53 | func (w *Workflow) ApplyEvent(otx *sql.Tx, event *DocEvent, recipients []GroupID) (DocStateID, error) { 54 | if !w.Active { 55 | return 0, ErrWorkflowInactive 56 | } 57 | if event.Status == EventStatusApplied { 58 | return 0, ErrDocEventAlreadyApplied 59 | } 60 | if w.DocType.ID != event.DocType { 61 | return 0, ErrDocEventDocTypeMismatch 62 | } 63 | 64 | n, err := Nodes.GetByState(w.DocType.ID, event.State) 65 | if err != nil { 66 | return 0, err 67 | } 68 | 69 | var gt string 70 | tq := `SELECT group_type FROM wf_groups_master WHERE id = ?` 71 | row := db.QueryRow(tq, event.Group) 72 | err = row.Scan(>) 73 | if err != nil { 74 | return 0, err 75 | } 76 | if gt != "S" { 77 | return 0, errors.New("group must be singleton") 78 | } 79 | 80 | var tx *sql.Tx 81 | if otx == nil { 82 | tx, err = db.Begin() 83 | if err != nil { 84 | return 0, err 85 | } 86 | defer tx.Rollback() 87 | } else { 88 | tx = otx 89 | } 90 | 91 | nstate, err := n.applyEvent(tx, event, recipients) 92 | if err != nil { 93 | return 0, err 94 | } 95 | 96 | if otx == nil { 97 | err = tx.Commit() 98 | if err != nil { 99 | return 0, err 100 | } 101 | } 102 | 103 | return nstate, nil 104 | } 105 | 106 | // Unexported type, only for convenience methods. 107 | type _Workflows struct{} 108 | 109 | // Workflows provides a resource-like interface to the workflows 110 | // defined in the system. 111 | var Workflows _Workflows 112 | 113 | // New creates and initialises a workflow definition using the given 114 | // name, the document type whose life cycle this workflow should 115 | // manage, and the initial document state in which this workflow 116 | // begins. 117 | // 118 | // N.B. Workflow names must be globally-unique. 119 | func (_Workflows) New(otx *sql.Tx, name string, dtype DocTypeID, state DocStateID) (WorkflowID, error) { 120 | name = strings.TrimSpace(name) 121 | if name == "" { 122 | return 0, errors.New("name should not be empty") 123 | } 124 | if dtype <= 0 { 125 | return 0, errors.New("document type should be a positive integer") 126 | } 127 | if state <= 1 { 128 | return 0, errors.New("initial document state should be an integer > 1") 129 | } 130 | 131 | var tx *sql.Tx 132 | var err error 133 | if otx == nil { 134 | tx, err = db.Begin() 135 | if err != nil { 136 | return 0, err 137 | } 138 | defer tx.Rollback() 139 | } else { 140 | tx = otx 141 | } 142 | 143 | q := ` 144 | INSERT INTO wf_workflows(name, doctype_id, docstate_id, active) 145 | VALUES(?, ?, ?, 1) 146 | ` 147 | res, err := tx.Exec(q, name, dtype, state) 148 | if err != nil { 149 | return 0, err 150 | } 151 | id, err := res.LastInsertId() 152 | if err != nil { 153 | return 0, err 154 | } 155 | 156 | if otx == nil { 157 | err = tx.Commit() 158 | if err != nil { 159 | return 0, err 160 | } 161 | } 162 | 163 | return WorkflowID(id), nil 164 | } 165 | 166 | // List answers a subset of the workflows defined in the system, 167 | // according to the given specification. 168 | // 169 | // Result set begins with ID >= `offset`, and has not more than 170 | // `limit` elements. A value of `0` for `offset` fetches from the 171 | // beginning, while a value of `0` for `limit` fetches until the end. 172 | func (_Workflows) List(offset, limit int64) ([]*Workflow, error) { 173 | if offset < 0 || limit < 0 { 174 | return nil, errors.New("offset and limit must be non-negative integers") 175 | } 176 | if limit == 0 { 177 | limit = math.MaxInt64 178 | } 179 | 180 | q := ` 181 | SELECT wf.id, wf.name, dtm.id, dtm.name, dsm.id, dsm.name, wf.active 182 | FROM wf_workflows wf 183 | JOIN wf_doctypes_master dtm ON wf.doctype_id = dtm.id 184 | JOIN wf_docstates_master dsm ON wf.docstate_id = dsm.id 185 | ORDER BY wf.id 186 | LIMIT ? OFFSET ? 187 | ` 188 | rows, err := db.Query(q, limit, offset) 189 | if err != nil { 190 | return nil, err 191 | } 192 | defer rows.Close() 193 | 194 | ary := make([]*Workflow, 0, 10) 195 | for rows.Next() { 196 | var elem Workflow 197 | err = rows.Scan(&elem.ID, &elem.Name, &elem.DocType.ID, &elem.DocType.Name, 198 | &elem.BeginState.ID, &elem.BeginState.Name, &elem.Active) 199 | if err != nil { 200 | return nil, err 201 | } 202 | ary = append(ary, &elem) 203 | } 204 | if err = rows.Err(); err != nil { 205 | return nil, err 206 | } 207 | 208 | return ary, nil 209 | } 210 | 211 | // Get retrieves the details of the requested workflow from the 212 | // database. 213 | // 214 | // N.B. This method retrieves the primary information of the 215 | // workflow. Information of the nodes comprising this workflow have 216 | // to be fetched separately. 217 | func (_Workflows) Get(id WorkflowID) (*Workflow, error) { 218 | q := ` 219 | SELECT wf.id, wf.name, dtm.id, dtm.name, dsm.id, dsm.name, wf.active 220 | FROM wf_workflows wf 221 | JOIN wf_doctypes_master dtm ON dtm.id = wf.doctype_id 222 | JOIN wf_docstates_master dsm ON dsm.id = wf.docstate_id 223 | WHERE wf.id = ? 224 | ` 225 | row := db.QueryRow(q, id) 226 | var elem Workflow 227 | err := row.Scan(&elem.ID, &elem.Name, &elem.DocType.ID, &elem.DocType.Name, 228 | &elem.BeginState.ID, &elem.BeginState.Name, &elem.Active) 229 | if err != nil { 230 | return nil, err 231 | } 232 | 233 | return &elem, nil 234 | } 235 | 236 | // GetByDocType retrieves the details of the requested workflow from 237 | // the database. 238 | // 239 | // N.B. This method retrieves the primary information of the 240 | // workflow. Information of the nodes comprising this workflow have 241 | // to be fetched separately. 242 | func (_Workflows) GetByDocType(dtid DocTypeID) (*Workflow, error) { 243 | q := ` 244 | SELECT wf.id, wf.name, dtm.id, dtm.name, dsm.id, dsm.name, wf.active 245 | FROM wf_workflows wf 246 | JOIN wf_doctypes_master dtm ON dtm.id = wf.doctype_id 247 | JOIN wf_docstates_master dsm ON dsm.id = wf.docstate_id 248 | WHERE wf.doctype_id = ? 249 | ` 250 | row := db.QueryRow(q, dtid) 251 | var elem Workflow 252 | err := row.Scan(&elem.ID, &elem.Name, &elem.DocType.ID, &elem.DocType.Name, 253 | &elem.BeginState.ID, &elem.BeginState.Name, &elem.Active) 254 | if err != nil { 255 | return nil, err 256 | } 257 | 258 | return &elem, nil 259 | } 260 | 261 | // GetByName retrieves the details of the requested workflow from the 262 | // database. 263 | // 264 | // N.B. This method retrieves the primary information of the 265 | // workflow. Information of the nodes comprising this workflow have 266 | // to be fetched separately. 267 | func (_Workflows) GetByName(name string) (*Workflow, error) { 268 | q := ` 269 | SELECT wf.id, wf.name, dtm.id, dtm.name, dsm.id, dsm.name, wf.active 270 | FROM wf_workflows wf 271 | JOIN wf_doctypes_master dtm ON wf.doctype_id = dtm.id 272 | JOIN wf_docstates_master dsm ON wf.docstate_id = dsm.id 273 | WHERE wf.name = ? 274 | ` 275 | row := db.QueryRow(q, name) 276 | var elem Workflow 277 | err := row.Scan(&elem.ID, &elem.Name, &elem.DocType.ID, &elem.DocType.Name, 278 | &elem.BeginState.ID, &elem.BeginState.Name, &elem.Active) 279 | if err != nil { 280 | return nil, err 281 | } 282 | 283 | return &elem, nil 284 | } 285 | 286 | // Rename assigns a new name to the given workflow. 287 | func (_Workflows) Rename(otx *sql.Tx, id WorkflowID, name string) error { 288 | name = strings.TrimSpace(name) 289 | if name == "" { 290 | return errors.New("name should be non-empty") 291 | } 292 | 293 | var tx *sql.Tx 294 | var err error 295 | if otx == nil { 296 | tx, err = db.Begin() 297 | if err != nil { 298 | return err 299 | } 300 | defer tx.Rollback() 301 | } else { 302 | tx = otx 303 | } 304 | 305 | q := ` 306 | UPDATE wf_workflows SET name = ? 307 | WHERE id = ? 308 | ` 309 | _, err = tx.Exec(q, name, id) 310 | if err != nil { 311 | return err 312 | } 313 | 314 | if otx == nil { 315 | err = tx.Commit() 316 | if err != nil { 317 | return err 318 | } 319 | } 320 | 321 | return nil 322 | } 323 | 324 | // SetActive sets the status of the workflow as either active or 325 | // inactive, helping in workflow management and deprecation. 326 | func (_Workflows) SetActive(otx *sql.Tx, id WorkflowID, active bool) error { 327 | var tx *sql.Tx 328 | var err error 329 | if otx == nil { 330 | tx, err = db.Begin() 331 | if err != nil { 332 | return err 333 | } 334 | defer tx.Rollback() 335 | } else { 336 | tx = otx 337 | } 338 | 339 | var flag int 340 | if active { 341 | flag = 1 342 | } 343 | q := ` 344 | UPDATE wf_workflows SET active = ? 345 | WHERE id = ? 346 | ` 347 | _, err = tx.Exec(q, flag, id) 348 | if err != nil { 349 | return err 350 | } 351 | 352 | if otx == nil { 353 | err = tx.Commit() 354 | if err != nil { 355 | return err 356 | } 357 | } 358 | 359 | return nil 360 | } 361 | 362 | // AddNode maps the given document state to the specified node. This 363 | // map is consulted by the workflow when performing a state transition 364 | // of the system. 365 | func (_Workflows) AddNode(otx *sql.Tx, dtype DocTypeID, state DocStateID, 366 | ac AccessContextID, wid WorkflowID, name string, ntype NodeType) (NodeID, error) { 367 | name = strings.TrimSpace(name) 368 | if name == "" { 369 | return 0, errors.New("name should not be empty") 370 | } 371 | 372 | var tx *sql.Tx 373 | var err error 374 | if otx == nil { 375 | tx, err = db.Begin() 376 | if err != nil { 377 | return 0, err 378 | } 379 | defer tx.Rollback() 380 | } else { 381 | tx = otx 382 | } 383 | 384 | q := ` 385 | INSERT INTO wf_workflow_nodes(doctype_id, docstate_id, ac_id, workflow_id, name, type) 386 | VALUES(?, ?, ?, ?, ?, ?) 387 | ` 388 | res, err := tx.Exec(q, dtype, state, ac, wid, name, string(ntype)) 389 | if err != nil { 390 | return 0, err 391 | } 392 | id, err := res.LastInsertId() 393 | if err != nil { 394 | return 0, err 395 | } 396 | 397 | if otx == nil { 398 | err = tx.Commit() 399 | if err != nil { 400 | return 0, err 401 | } 402 | } 403 | 404 | return NodeID(id), nil 405 | } 406 | 407 | // RemoveNode unmaps the given document state to the specified node. 408 | // This map is consulted by the workflow when performing a state 409 | // transition of the system. 410 | func (_Workflows) RemoveNode(otx *sql.Tx, wid WorkflowID, nid NodeID) error { 411 | var tx *sql.Tx 412 | var err error 413 | if otx == nil { 414 | tx, err = db.Begin() 415 | if err != nil { 416 | return err 417 | } 418 | defer tx.Rollback() 419 | } else { 420 | tx = otx 421 | } 422 | 423 | q := ` 424 | DELETE FROM wf_workflow_nodes 425 | WHERE workflow_id = ? 426 | AND id = ? 427 | ` 428 | _, err = tx.Exec(q, wid, nid) 429 | if err != nil { 430 | return err 431 | } 432 | 433 | if otx == nil { 434 | err = tx.Commit() 435 | if err != nil { 436 | return err 437 | } 438 | } 439 | 440 | return nil 441 | } 442 | --------------------------------------------------------------------------------