From 8d43907bd693f67e9435084134422d2cc547fe69 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Wed, 27 Feb 2019 08:37:19 +0100 Subject: [PATCH] Basic (rest) structure for page & record/module attachments --- api/crm/spec.json | 283 ++++++++++--- api/crm/spec/attachment.json | 137 +++++++ api/crm/spec/module.json | 176 -------- api/crm/spec/page.json | 24 ++ api/crm/spec/record.json | 191 +++++++++ codegen.sh | 6 +- codegen/codegen.php | 1 + crm/db/mysql/statik.go | 2 +- .../mysql/20190227090642.attachment.up.sql | 24 ++ crm/repository/attachment.go | 161 ++++++++ crm/rest/attachment.go | 53 +++ crm/rest/attachment_custom.go | 119 ++++++ crm/rest/handlers/attachment.go | 87 ++++ crm/rest/handlers/attachment_custom.go | 61 +++ crm/rest/handlers/module.go | 70 +--- crm/rest/handlers/page.go | 10 + crm/rest/handlers/record.go | 117 ++++++ crm/rest/module.go | 47 +-- crm/rest/page.go | 29 +- crm/rest/pageattachment.go_ | 67 +++ crm/rest/record.go | 79 ++++ crm/rest/recordattachment.go_ | 69 ++++ crm/rest/request/attachment.go | 247 +++++++++++ crm/rest/request/module.go | 362 ----------------- crm/rest/request/page.go | 108 +++-- crm/rest/request/record.go | 384 ++++++++++++++++++ crm/rest/request/util.go | 9 + crm/rest/router.go | 8 + crm/service/attachment.go | 287 +++++++++++++ crm/service/service.go | 10 + crm/types/attachment.gen.go | 67 +++ crm/types/attachment.go | 104 +++++ crm/types/types.go | 2 +- docs/crm/README.md | 304 +++++++++----- messaging/service/service.go | 2 +- system/db/mysql/statik.go | 2 +- 36 files changed, 2849 insertions(+), 860 deletions(-) create mode 100644 api/crm/spec/attachment.json create mode 100644 api/crm/spec/record.json create mode 100644 crm/db/schema/mysql/20190227090642.attachment.up.sql create mode 100644 crm/repository/attachment.go create mode 100644 crm/rest/attachment.go create mode 100644 crm/rest/attachment_custom.go create mode 100644 crm/rest/handlers/attachment.go create mode 100644 crm/rest/handlers/attachment_custom.go create mode 100644 crm/rest/handlers/record.go create mode 100644 crm/rest/pageattachment.go_ create mode 100644 crm/rest/record.go create mode 100644 crm/rest/recordattachment.go_ create mode 100644 crm/rest/request/attachment.go create mode 100644 crm/rest/request/record.go create mode 100644 crm/service/attachment.go create mode 100644 crm/types/attachment.gen.go create mode 100644 crm/types/attachment.go diff --git a/api/crm/spec.json b/api/crm/spec.json index 14580a812..498f36c87 100644 --- a/api/crm/spec.json +++ b/api/crm/spec.json @@ -191,6 +191,30 @@ } ] } + }, + { + "name": "upload", + "path": "/{pageID}/attachment", + "method": "POST", + "title": "Uploads attachment to page", + "parameters": { + "path": [ + { + "type": "uint64", + "name": "pageID", + "required": true, + "title": "Page ID" + } + ], + "post": [ + { + "name": "upload", + "type": "*multipart.FileHeader", + "required": true, + "title": "File to upload" + } + ] + } } ] }, @@ -320,21 +344,39 @@ } ] } - }, + } + ] + }, + { + "title": "Records", + "description": "CRM records ", + "entrypoint": "record", + "path": "/module/{moduleID}", + "authentication": [], + "struct": [ { - "name": "record/report", + "imports": [ + "github.com/crusttech/crust/crm/types" + ] + } + ], + "parameters": { + "path": [ + { + "type": "uint64", + "name": "moduleID", + "required": true, + "title": "Module ID" + } + ] + }, + "apis": [ + { + "name": "report", "method": "GET", "title": "Generates report from module records", - "path": "/{moduleID}/report", + "path": "/report", "parameters": { - "path": [ - { - "type": "uint64", - "name": "moduleID", - "required": true, - "title": "Module ID" - } - ], "get": [ { "type": "string", @@ -358,19 +400,11 @@ } }, { - "name": "record/list", + "name": "list", "method": "GET", "title": "List/read records from module section", - "path": "/{moduleID}/record", + "path": "/record", "parameters": { - "path": [ - { - "type": "uint64", - "name": "moduleID", - "required": true, - "title": "Module ID" - } - ], "get": [ { "name": "filter", @@ -400,19 +434,11 @@ } }, { - "name": "record/create", + "name": "create", "method": "POST", "title": "Create record in module section", - "path": "/{moduleID}/record", + "path": "/record", "parameters": { - "path": [ - { - "type": "uint64", - "name": "moduleID", - "required": true, - "title": "Module ID" - } - ], "post": [ { "type": "types.RecordValueSet", @@ -424,18 +450,12 @@ } }, { - "name": "record/read", + "name": "read", "method": "GET", "title": "Read records by ID from module section", - "path": "/{moduleID}/record/{recordID}", + "path": "/record/{recordID}", "parameters": { "path": [ - { - "type": "uint64", - "name": "moduleID", - "required": true, - "title": "Module ID" - }, { "type": "uint64", "name": "recordID", @@ -446,18 +466,12 @@ } }, { - "name": "record/update", + "name": "update", "method": "POST", "title": "Update records in module section", - "path": "/{moduleID}/record/{recordID}", + "path": "/record/{recordID}", "parameters": { "path": [ - { - "type": "uint64", - "name": "moduleID", - "required": true, - "title": "Module ID" - }, { "type": "uint64", "name": "recordID", @@ -476,18 +490,12 @@ } }, { - "name": "record/delete", + "name": "delete", "method": "DELETE", "title": "Delete record row from module section", - "path": "/{moduleID}/record/{recordID}", + "path": "/record/{recordID}", "parameters": { "path": [ - { - "type": "uint64", - "name": "moduleID", - "required": true, - "title": "Module ID" - }, { "type": "uint64", "name": "recordID", @@ -496,6 +504,36 @@ } ] } + }, + { + "name": "upload", + "path": "/record/{recordID}/{fieldName}/attachment", + "method": "POST", + "title": "Uploads attachment and validates it against record field requirements", + "parameters": { + "path": [ + { + "name": "recordID", + "type": "uint64", + "required": true, + "title": "Record ID" + }, + { + "name": "fieldName", + "type": "string", + "required": true, + "title": "Field name" + } + ], + "post": [ + { + "name": "upload", + "type": "*multipart.FileHeader", + "required": true, + "title": "File to upload" + } + ] + } } ] }, @@ -779,5 +817,140 @@ } } ] + }, + { + "title": "Attachments", + "path": "/attachment/{kind}", + "entrypoint": "attachment", + "authentication": [ + "Client ID", + "Session ID" + ], + "parameters": { + "path": [ + { + "name": "kind", + "type": "string", + "required": true, + "title": "Attachment kind" + } + ] + }, + "apis": [ + { + "name": "list", + "path": "/", + "method": "GET", + "title": "List, filter all page attachments", + "parameters": { + "get": [ + { + "name": "pageID", + "type": "uint64", + "required": false, + "title": "Filter attachments by page ID" + }, + { + "type": "uint64", + "name": "moduleID", + "required": false, + "title": "Filter attachments by mnodule ID" + }, + { + "name": "recordID", + "type": "uint64", + "required": false, + "title": "Filter attachments by record ID" + }, + { + "name": "fieldName", + "type": "string", + "required": false, + "title": "Filter attachments by field name" + }, + { + "name": "page", + "type": "uint", + "required": false, + "title": "Page number (0 based)" + }, + { + "name": "perPage", + "type": "uint", + "required": false, + "title": "Returned items per page (default 50)" + } + ] + } + }, + { + "name": "details", + "path": "/{attachmentID}", + "method": "GET", + "title": "Attachment details", + "parameters": { + "path": [ + { + "name": "attachmentID", + "type": "uint64", + "required": true, + "title": "Attachment ID" + } + ] + } + }, + { + "name": "original", + "path": "/{attachmentID}/original/{name}", + "method": "GET", + "title": "Serves attached file", + "parameters": { + "path": [ + { + "name": "attachmentID", + "type": "uint64", + "required": true, + "title": "Attachment ID" + }, + { + "name": "name", + "type": "string", + "required": true, + "title": "File name" + } + ], + "get": [ + { + "type": "bool", + "name": "download", + "required": false, + "title": "Force file download" + } + ] + } + }, + { + "name": "preview", + "path": "/{attachmentID}/preview.{ext}", + "method": "GET", + "title": "Serves preview of an attached file", + "parameters": { + "path": [ + { + "name": "attachmentID", + "type": "uint64", + "required": true, + "title": "Attachment ID" + }, + { + "name": "ext", + "type": "string", + "required": true, + "title": "Preview extension/format" + } + ] + } + } + ] } ] diff --git a/api/crm/spec/attachment.json b/api/crm/spec/attachment.json new file mode 100644 index 000000000..f5c161691 --- /dev/null +++ b/api/crm/spec/attachment.json @@ -0,0 +1,137 @@ +{ + "Title": "Attachments", + "Interface": "Attachment", + "Struct": null, + "Parameters": { + "path": [ + { + "name": "kind", + "required": true, + "title": "Attachment kind", + "type": "string" + } + ] + }, + "Protocol": "", + "Authentication": [ + "Client ID", + "Session ID" + ], + "Path": "/attachment/{kind}", + "APIs": [ + { + "Name": "list", + "Method": "GET", + "Title": "List, filter all page attachments", + "Path": "/", + "Parameters": { + "get": [ + { + "name": "pageID", + "required": false, + "title": "Filter attachments by page ID", + "type": "uint64" + }, + { + "name": "moduleID", + "required": false, + "title": "Filter attachments by mnodule ID", + "type": "uint64" + }, + { + "name": "recordID", + "required": false, + "title": "Filter attachments by record ID", + "type": "uint64" + }, + { + "name": "fieldName", + "required": false, + "title": "Filter attachments by field name", + "type": "string" + }, + { + "name": "page", + "required": false, + "title": "Page number (0 based)", + "type": "uint" + }, + { + "name": "perPage", + "required": false, + "title": "Returned items per page (default 50)", + "type": "uint" + } + ] + } + }, + { + "Name": "details", + "Method": "GET", + "Title": "Attachment details", + "Path": "/{attachmentID}", + "Parameters": { + "path": [ + { + "name": "attachmentID", + "required": true, + "title": "Attachment ID", + "type": "uint64" + } + ] + } + }, + { + "Name": "original", + "Method": "GET", + "Title": "Serves attached file", + "Path": "/{attachmentID}/original/{name}", + "Parameters": { + "get": [ + { + "name": "download", + "required": false, + "title": "Force file download", + "type": "bool" + } + ], + "path": [ + { + "name": "attachmentID", + "required": true, + "title": "Attachment ID", + "type": "uint64" + }, + { + "name": "name", + "required": true, + "title": "File name", + "type": "string" + } + ] + } + }, + { + "Name": "preview", + "Method": "GET", + "Title": "Serves preview of an attached file", + "Path": "/{attachmentID}/preview.{ext}", + "Parameters": { + "path": [ + { + "name": "attachmentID", + "required": true, + "title": "Attachment ID", + "type": "uint64" + }, + { + "name": "ext", + "required": true, + "title": "Preview extension/format", + "type": "string" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/api/crm/spec/module.json b/api/crm/spec/module.json index e10925d11..4d55ad861 100644 --- a/api/crm/spec/module.json +++ b/api/crm/spec/module.json @@ -126,182 +126,6 @@ } ] } - }, - { - "Name": "record/report", - "Method": "GET", - "Title": "Generates report from module records", - "Path": "/{moduleID}/report", - "Parameters": { - "get": [ - { - "name": "metrics", - "required": false, - "title": "Metrics (eg: 'SUM(money), MAX(calls)')", - "type": "string" - }, - { - "name": "dimensions", - "required": true, - "title": "Dimensions (eg: 'DATE(foo), status')", - "type": "string" - }, - { - "name": "filter", - "required": false, - "title": "Filter (eg: 'DATE(foo) \u003e 2010')", - "type": "string" - } - ], - "path": [ - { - "name": "moduleID", - "required": true, - "title": "Module ID", - "type": "uint64" - } - ] - } - }, - { - "Name": "record/list", - "Method": "GET", - "Title": "List/read records from module section", - "Path": "/{moduleID}/record", - "Parameters": { - "get": [ - { - "name": "filter", - "required": false, - "title": "Filtering condition", - "type": "string" - }, - { - "name": "page", - "required": false, - "title": "Page number (0 based)", - "type": "int" - }, - { - "name": "perPage", - "required": false, - "title": "Returned items per page (default 50)", - "type": "int" - }, - { - "name": "sort", - "required": false, - "title": "Sort field (default id desc)", - "type": "string" - } - ], - "path": [ - { - "name": "moduleID", - "required": true, - "title": "Module ID", - "type": "uint64" - } - ] - } - }, - { - "Name": "record/create", - "Method": "POST", - "Title": "Create record in module section", - "Path": "/{moduleID}/record", - "Parameters": { - "path": [ - { - "name": "moduleID", - "required": true, - "title": "Module ID", - "type": "uint64" - } - ], - "post": [ - { - "name": "values", - "required": true, - "title": "Record values", - "type": "types.RecordValueSet" - } - ] - } - }, - { - "Name": "record/read", - "Method": "GET", - "Title": "Read records by ID from module section", - "Path": "/{moduleID}/record/{recordID}", - "Parameters": { - "path": [ - { - "name": "moduleID", - "required": true, - "title": "Module ID", - "type": "uint64" - }, - { - "name": "recordID", - "required": true, - "title": "Record ID", - "type": "uint64" - } - ] - } - }, - { - "Name": "record/update", - "Method": "POST", - "Title": "Update records in module section", - "Path": "/{moduleID}/record/{recordID}", - "Parameters": { - "path": [ - { - "name": "moduleID", - "required": true, - "title": "Module ID", - "type": "uint64" - }, - { - "name": "recordID", - "required": true, - "title": "Record ID", - "type": "uint64" - } - ], - "post": [ - { - "name": "values", - "required": true, - "title": "Record values", - "type": "types.RecordValueSet" - } - ] - } - }, - { - "Name": "record/delete", - "Method": "DELETE", - "Title": "Delete record row from module section", - "Path": "/{moduleID}/record/{recordID}", - "Parameters": { - "path": [ - { - "name": "moduleID", - "required": true, - "title": "Module ID", - "type": "uint64" - }, - { - "name": "recordID", - "required": true, - "title": "Record ID", - "type": "uint64" - } - ] - } } ] } \ No newline at end of file diff --git a/api/crm/spec/page.json b/api/crm/spec/page.json index d8486a517..0d50d05c9 100644 --- a/api/crm/spec/page.json +++ b/api/crm/spec/page.json @@ -192,6 +192,30 @@ } ] } + }, + { + "Name": "upload", + "Method": "POST", + "Title": "Uploads attachment to page", + "Path": "/{pageID}/attachment", + "Parameters": { + "path": [ + { + "name": "pageID", + "required": true, + "title": "Page ID", + "type": "uint64" + } + ], + "post": [ + { + "name": "upload", + "required": true, + "title": "File to upload", + "type": "*multipart.FileHeader" + } + ] + } } ] } \ No newline at end of file diff --git a/api/crm/spec/record.json b/api/crm/spec/record.json new file mode 100644 index 000000000..d36c64d34 --- /dev/null +++ b/api/crm/spec/record.json @@ -0,0 +1,191 @@ +{ + "Title": "Records", + "Description": "CRM records ", + "Interface": "Record", + "Struct": [ + { + "imports": [ + "github.com/crusttech/crust/crm/types" + ] + } + ], + "Parameters": { + "path": [ + { + "name": "moduleID", + "required": true, + "title": "Module ID", + "type": "uint64" + } + ] + }, + "Protocol": "", + "Authentication": [], + "Path": "/module/{moduleID}", + "APIs": [ + { + "Name": "report", + "Method": "GET", + "Title": "Generates report from module records", + "Path": "/report", + "Parameters": { + "get": [ + { + "name": "metrics", + "required": false, + "title": "Metrics (eg: 'SUM(money), MAX(calls)')", + "type": "string" + }, + { + "name": "dimensions", + "required": true, + "title": "Dimensions (eg: 'DATE(foo), status')", + "type": "string" + }, + { + "name": "filter", + "required": false, + "title": "Filter (eg: 'DATE(foo) \u003e 2010')", + "type": "string" + } + ] + } + }, + { + "Name": "list", + "Method": "GET", + "Title": "List/read records from module section", + "Path": "/record", + "Parameters": { + "get": [ + { + "name": "filter", + "required": false, + "title": "Filtering condition", + "type": "string" + }, + { + "name": "page", + "required": false, + "title": "Page number (0 based)", + "type": "int" + }, + { + "name": "perPage", + "required": false, + "title": "Returned items per page (default 50)", + "type": "int" + }, + { + "name": "sort", + "required": false, + "title": "Sort field (default id desc)", + "type": "string" + } + ] + } + }, + { + "Name": "create", + "Method": "POST", + "Title": "Create record in module section", + "Path": "/record", + "Parameters": { + "post": [ + { + "name": "values", + "required": true, + "title": "Record values", + "type": "types.RecordValueSet" + } + ] + } + }, + { + "Name": "read", + "Method": "GET", + "Title": "Read records by ID from module section", + "Path": "/record/{recordID}", + "Parameters": { + "path": [ + { + "name": "recordID", + "required": true, + "title": "Record ID", + "type": "uint64" + } + ] + } + }, + { + "Name": "update", + "Method": "POST", + "Title": "Update records in module section", + "Path": "/record/{recordID}", + "Parameters": { + "path": [ + { + "name": "recordID", + "required": true, + "title": "Record ID", + "type": "uint64" + } + ], + "post": [ + { + "name": "values", + "required": true, + "title": "Record values", + "type": "types.RecordValueSet" + } + ] + } + }, + { + "Name": "delete", + "Method": "DELETE", + "Title": "Delete record row from module section", + "Path": "/record/{recordID}", + "Parameters": { + "path": [ + { + "name": "recordID", + "required": true, + "title": "Record ID", + "type": "uint64" + } + ] + } + }, + { + "Name": "upload", + "Method": "POST", + "Title": "Uploads attachment and validates it against record field requirements", + "Path": "/record/{recordID}/{fieldName}/attachment", + "Parameters": { + "path": [ + { + "name": "recordID", + "required": true, + "title": "Record ID", + "type": "uint64" + }, + { + "name": "fieldName", + "required": true, + "title": "Field name", + "type": "string" + } + ], + "post": [ + { + "name": "upload", + "required": true, + "title": "File to upload", + "type": "*multipart.FileHeader" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/codegen.sh b/codegen.sh index 7c9d0308f..439e82ef6 100755 --- a/codegen.sh +++ b/codegen.sh @@ -23,9 +23,11 @@ function types { fi ./build/gen-type-set --types Module,Page,Chart,Trigger,Record \ - --output crm/types/type.primary.gen.go + --output crm/types/type.primary.gen.go ./build/gen-type-set --with-primary-key=false --types ModuleField,RecordValue \ - --output crm/types/type.other.gen.go + --output crm/types/type.other.gen.go + ./build/gen-type-set --types Attachment \ + --output crm/types/attachment.gen.go ./build/gen-type-set --types MessageAttachment --output messaging/types/attachment.gen.go ./build/gen-type-set --with-resources=true --types Channel --resource-type "rules.Resource" --imports "github.com/crusttech/crust/internal/rules" --output messaging/types/channel.gen.go diff --git a/codegen/codegen.php b/codegen/codegen.php index 6ed3212b2..d1aed6cf0 100755 --- a/codegen/codegen.php +++ b/codegen/codegen.php @@ -98,6 +98,7 @@ $parsers = array( "uint64" => "parseUInt64", "[]uint64" => "parseUInt64A", "int" => "parseInt", + "uint" => "parseUint", "bool" => "parseBool", "sqlxTypes.JSONText" => "parseJSONTextWithErr", ); diff --git a/crm/db/mysql/statik.go b/crm/db/mysql/statik.go index 200048808..c8986b219 100644 --- a/crm/db/mysql/statik.go +++ b/crm/db/mysql/statik.go @@ -8,7 +8,7 @@ import ( ) func Data() string { - return "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `crm_content` (\n `id` bigint(20) unsigned NOT NULL,\n `module_id` bigint(20) unsigned NOT NULL,\n `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_at` datetime DEFAULT NULL,\n `deleted_at` datetime DEFAULT NULL,\n PRIMARY KEY (`id`,`module_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_content_column` (\n `content_id` bigint(20) NOT NULL,\n `column_name` varchar(255) NOT NULL,\n `column_value` text NOT NULL,\n PRIMARY KEY (`content_id`,`column_name`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_field` (\n `field_type` varchar(16) NOT NULL COMMENT 'Short field type (string, boolean,...)',\n `field_name` varchar(255) NOT NULL COMMENT 'Description of field contents',\n `field_template` varchar(255) NOT NULL COMMENT 'HTML template file for field',\n PRIMARY KEY (`field_type`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_module` (\n `id` bigint(20) unsigned NOT NULL,\n `name` varchar(64) NOT NULL COMMENT 'The name of the module',\n `json` json NOT NULL COMMENT 'List of field definitions for the module',\n `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_at` datetime DEFAULT NULL,\n `deleted_at` datetime DEFAULT NULL,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_module_form` (\n `module_id` bigint(20) unsigned NOT NULL,\n `place` tinyint(3) unsigned NOT NULL,\n `kind` varchar(64) NOT NULL COMMENT 'The type of the form input field',\n `name` varchar(64) NOT NULL COMMENT 'The name of the field in the form',\n `label` varchar(255) NOT NULL COMMENT 'The label of the form input',\n `help_text` text NOT NULL COMMENT 'Help text',\n `default_value` text NOT NULL COMMENT 'Default value',\n `max_length` int(10) unsigned NOT NULL COMMENT 'Maximum input length',\n `is_private` tinyint(1) NOT NULL COMMENT 'Contains personal/sensitive data?',\n PRIMARY KEY (`module_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_page` (\n `id` bigint(20) unsigned NOT NULL COMMENT 'Page ID',\n `self_id` bigint(20) unsigned NOT NULL COMMENT 'Parent Page ID',\n `module_id` bigint(20) unsigned NOT NULL COMMENT 'Module ID (optional)',\n `title` varchar(255) NOT NULL COMMENT 'Title (required)',\n `description` text NOT NULL COMMENT 'Description',\n `blocks` json NOT NULL COMMENT 'JSON array of blocks for the page',\n `visible` tinyint(4) NOT NULL COMMENT 'Is page visible in navigation?',\n `weight` int(11) NOT NULL COMMENT 'Order for navigation',\n PRIMARY KEY (`id`) USING BTREE,\n KEY `module_id` (`module_id`),\n KEY `self_id` (`self_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\xac\xe8\x19\x1d\x12\n\x00\x00\x12\n\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00%\x00 \x0020180704080001.crm_fields-data.up.sqlUT\x05\x00\x01\x80Cm8INSERT INTO `crm_field` VALUES ('bool','Boolean value (yes / no)','');\nINSERT INTO `crm_field` VALUES ('email','E-mail input','');\nINSERT INTO `crm_field` VALUES ('enum','Single option picker','');\nINSERT INTO `crm_field` VALUES ('hidden','Hidden value','');\nINSERT INTO `crm_field` VALUES ('stamp','Date/time input','');\nINSERT INTO `crm_field` VALUES ('text','Text input','');\nINSERT INTO `crm_field` VALUES ('textarea','Text input (multi-line)','');\nPK\x07\x08f\x18\x1e\x84\xc5\x01\x00\x00\xc5\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00+\x00 \x0020181109133134.crm_content-ownership.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_content` ADD `user_id` BIGINT UNSIGNED NOT NULL AFTER `module_id`, ADD INDEX (`user_id`);\nPK\x07\x08\xeb!\x81\xc2k\x00\x00\x00k\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00.\x00 \x0020181109193047.crm_fields-related_types.up.sqlUT\x05\x00\x01\x80Cm8INSERT INTO `crm_field` (`field_type`, `field_name`, `field_template`) VALUES ('related', 'Related content', ''), ('related_multi', 'Related content (multiple)', '');PK\x07\x08:.\xfb8\xa6\x00\x00\x00\xa6\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x000\x00 \x0020181125122152.add_multiple_relationships.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `crm_content_links` (\n `content_id` bigint(20) unsigned NOT NULL,\n `column_name` varchar(255) NOT NULL,\n `rel_content_id` bigint(20) unsigned NOT NULL,\n PRIMARY KEY (`content_id`,`column_name`,`rel_content_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;PK\x07\x08\xee\x12\x15 \x05\x01\x00\x00\x05\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00D\x00 \x0020181125132142.add_required_and_visible_to_module_form_fields.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_module_form` ADD `is_required` TINYINT(1) NOT NULL AFTER `is_private`, ADD `is_visible` TINYINT(1) NOT NULL AFTER `is_required`;PK\x07\x08\xa5q c\x91\x00\x00\x00\x91\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x005\x00 \x0020181202163130.fix-crm-module-form-primary-key.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_module_form` DROP PRIMARY KEY, ADD PRIMARY KEY(`module_id`, `place`);\nPK\x07\x08\xd9\xd4i\xe3W\x00\x00\x00W\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x000\x00 \x0020181204123650.add-crm-content-json-field.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_content` ADD `json` json DEFAULT NULL COMMENT 'Content in JSON format.' AFTER `user_id`;\nPK\x07\x08\"\x96\xd6pj\x00\x00\x00j\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x004\x00 \x0020181204155326.add-crm-module-form-json-field.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_module_form` ADD `json` JSON NOT NULL COMMENT 'Options in JSON format.' AFTER `kind`;PK\x07\x08\xb7\x93\xd4\xf6f\x00\x00\x00f\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00+\x00 \x0020181216214630.crm-content-to-record.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_content` RENAME TO `crm_record`;\nALTER TABLE `crm_record` MODIFY COLUMN `json` json DEFAULT NULL COMMENT 'Records in JSON format.';\n\nALTER TABLE `crm_content_column` RENAME TO `crm_record_column`;\nALTER TABLE `crm_record_column` CHANGE COLUMN `content_id` `record_id` bigint(20);\n\nALTER TABLE `crm_content_links` RENAME TO `crm_record_links`;\nALTER TABLE `crm_record_links` CHANGE COLUMN `content_id` `record_id` bigint(20) unsigned;\nALTER TABLE `crm_record_links` CHANGE COLUMN `rel_content_id` `rel_record_id` bigint(20) unsigned;\nPK\x07\x08mA\xa8\x1e&\x02\x00\x00&\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00$\x00 \x0020181217100000.add-charts-tbl.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `crm_chart` (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `name` VARCHAR(64) NOT NULL COMMENT 'The name of the chart',\n `config` JSON NOT NULL COMMENT 'Chart & reporting configuration',\n\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_at` DATETIME DEFAULT NULL,\n `deleted_at` DATETIME DEFAULT NULL,\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xcf\xc6g\xf6\xe4\x01\x00\x00\xe4\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020181224122301.rem-crm_field.up.sqlUT\x05\x00\x01\x80Cm8DROP TABLE `crm_field`;\nPK\x07\x08\xae \xfd2\x18\x00\x00\x00\x18\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00&\x00 \x0020190108100000.add-triggers-tbl.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `crm_trigger` (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `name` VARCHAR(64) NOT NULL COMMENT 'The name of the trigger',\n `enabled` BOOLEAN NOT NULL COMMENT 'Trigger enabled?',\n `actions` TEXT NOT NULL COMMENT 'All actions that trigger it',\n `source` TEXT NOT NULL COMMENT 'Trigger source',\n `rel_module` BIGINT(20) UNSIGNED NULL COMMENT 'Primary module',\n\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_at` DATETIME DEFAULT NULL,\n `deleted_at` DATETIME DEFAULT NULL,\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08+\xad\xb7\xed\xb8\x02\x00\x00\xb8\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00/\x00 \x0020190110175924.rem-crm-record-json-field.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_record` DROP COLUMN `json`;\nPK\x07\x08\x94#\xb9\x99-\x00\x00\x00-\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x008\x00 \x0020190114072000.cleanup-record-tables-and-multival.up.sqlUT\x05\x00\x01\x80Cm8-- No more links, we'll handle this through ref field on crm_record_value tbl\nDROP TABLE IF EXISTS `crm_record_links`;\n\n-- Not columns, values\nALTER TABLE `crm_record_column` RENAME TO `crm_record_value`;\n\n-- Simplify names\nALTER TABLE `crm_record_value` CHANGE COLUMN `column_name` `name` VARCHAR(64);\nALTER TABLE `crm_record_value` CHANGE COLUMN `column_value` `value` TEXT;\n\n-- Add reference\nALTER TABLE `crm_record_value` ADD COLUMN `ref` BIGINT UNSIGNED DEFAULT 0 NOT NULL;\nALTER TABLE `crm_record_value` ADD COLUMN `deleted_at` datetime DEFAULT NULL;\nALTER TABLE `crm_record_value` ADD COLUMN `place` INT UNSIGNED DEFAULT 0 NOT NULL;\nALTER TABLE `crm_record_value` DROP PRIMARY KEY, ADD PRIMARY KEY(`record_id`, `name`, `place`);\nCREATE INDEX crm_record_value_ref ON crm_record_value (ref);\n\n\n-- We want this as a real field\nALTER TABLE `crm_module_form` ADD COLUMN `is_multi` TINYINT(1) NOT NULL;\n\n-- This will be handled through meta(json) fieldd\nALTER TABLE `crm_module_form` DROP COLUMN `help_text`;\nALTER TABLE `crm_module_form` DROP COLUMN `max_length`;\nALTER TABLE `crm_module_form` DROP COLUMN `default_Value`;\nPK\x07\x08\x04]{\x1fo\x04\x00\x00o\x04\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\x00 \x0020190121132408.record-updated-by.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_record` CHANGE COLUMN `user_id` `owned_by` BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE `crm_record` ADD COLUMN `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE `crm_record` ADD COLUMN `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE `crm_record` ADD COLUMN `deleted_by` BIGINT UNSIGNED NOT NULL DEFAULT 0;\nUPDATE crm_record SET created_by = owned_by;\nUPDATE crm_record SET updated_by = owned_by WHERE updated_at IS NOT NULL;\nUPDATE crm_record SET deleted_by = owned_by WHERE deleted_at IS NOT NULL;\nPK\x07\x08h\xe2\xeb\n!\x02\x00\x00!\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` text NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x089S\x05%x\x01\x00\x00x\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x00 \x00new.shUT\x05\x00\x01\x80Cm8#!/bin/bash\ntouch $(date +%Y%m%d%H%M%S).up.sql\nPK\x07\x08\xc1h\xf1\xfb/\x00\x00\x00/\x00\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xac\xe8\x19\x1d\x12\n\x00\x00\x12\n\x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(f\x18\x1e\x84\xc5\x01\x00\x00\xc5\x01\x00\x00%\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81c\n\x00\x0020180704080001.crm_fields-data.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xeb!\x81\xc2k\x00\x00\x00k\x00\x00\x00+\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x84\x0c\x00\x0020181109133134.crm_content-ownership.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(:.\xfb8\xa6\x00\x00\x00\xa6\x00\x00\x00.\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81Q\x0d\x00\x0020181109193047.crm_fields-related_types.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xee\x12\x15 \x05\x01\x00\x00\x05\x01\x00\x000\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\\\x0e\x00\x0020181125122152.add_multiple_relationships.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xa5q c\x91\x00\x00\x00\x91\x00\x00\x00D\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc8\x0f\x00\x0020181125132142.add_required_and_visible_to_module_form_fields.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xd9\xd4i\xe3W\x00\x00\x00W\x00\x00\x005\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd4\x10\x00\x0020181202163130.fix-crm-module-form-primary-key.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\"\x96\xd6pj\x00\x00\x00j\x00\x00\x000\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x97\x11\x00\x0020181204123650.add-crm-content-json-field.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xb7\x93\xd4\xf6f\x00\x00\x00f\x00\x00\x004\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81h\x12\x00\x0020181204155326.add-crm-module-form-json-field.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(mA\xa8\x1e&\x02\x00\x00&\x02\x00\x00+\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x819\x13\x00\x0020181216214630.crm-content-to-record.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xcf\xc6g\xf6\xe4\x01\x00\x00\xe4\x01\x00\x00$\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc1\x15\x00\x0020181217100000.add-charts-tbl.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xae \xfd2\x18\x00\x00\x00\x18\x00\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x18\x00\x0020181224122301.rem-crm_field.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(+\xad\xb7\xed\xb8\x02\x00\x00\xb8\x02\x00\x00&\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81r\x18\x00\x0020190108100000.add-triggers-tbl.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x94#\xb9\x99-\x00\x00\x00-\x00\x00\x00/\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x87\x1b\x00\x0020190110175924.rem-crm-record-json-field.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x04]{\x1fo\x04\x00\x00o\x04\x00\x008\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x1a\x1c\x00\x0020190114072000.cleanup-record-tables-and-multival.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(h\xe2\xeb\n!\x02\x00\x00!\x02\x00\x00'\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf8 \x00\x0020190121132408.record-updated-by.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(9S\x05%x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81w#\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xc1h\xf1\xfb/\x00\x00\x00/\x00\x00\x00\x06\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xed\x814%\x00\x00new.shUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x12\x00\x12\x00\xbd\x06\x00\x00\xa0%\x00\x00\x00\x00" + return "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `crm_content` (\n `id` bigint(20) unsigned NOT NULL,\n `module_id` bigint(20) unsigned NOT NULL,\n `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_at` datetime DEFAULT NULL,\n `deleted_at` datetime DEFAULT NULL,\n PRIMARY KEY (`id`,`module_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_content_column` (\n `content_id` bigint(20) NOT NULL,\n `column_name` varchar(255) NOT NULL,\n `column_value` text NOT NULL,\n PRIMARY KEY (`content_id`,`column_name`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_field` (\n `field_type` varchar(16) NOT NULL COMMENT 'Short field type (string, boolean,...)',\n `field_name` varchar(255) NOT NULL COMMENT 'Description of field contents',\n `field_template` varchar(255) NOT NULL COMMENT 'HTML template file for field',\n PRIMARY KEY (`field_type`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_module` (\n `id` bigint(20) unsigned NOT NULL,\n `name` varchar(64) NOT NULL COMMENT 'The name of the module',\n `json` json NOT NULL COMMENT 'List of field definitions for the module',\n `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_at` datetime DEFAULT NULL,\n `deleted_at` datetime DEFAULT NULL,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_module_form` (\n `module_id` bigint(20) unsigned NOT NULL,\n `place` tinyint(3) unsigned NOT NULL,\n `kind` varchar(64) NOT NULL COMMENT 'The type of the form input field',\n `name` varchar(64) NOT NULL COMMENT 'The name of the field in the form',\n `label` varchar(255) NOT NULL COMMENT 'The label of the form input',\n `help_text` text NOT NULL COMMENT 'Help text',\n `default_value` text NOT NULL COMMENT 'Default value',\n `max_length` int(10) unsigned NOT NULL COMMENT 'Maximum input length',\n `is_private` tinyint(1) NOT NULL COMMENT 'Contains personal/sensitive data?',\n PRIMARY KEY (`module_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE `crm_page` (\n `id` bigint(20) unsigned NOT NULL COMMENT 'Page ID',\n `self_id` bigint(20) unsigned NOT NULL COMMENT 'Parent Page ID',\n `module_id` bigint(20) unsigned NOT NULL COMMENT 'Module ID (optional)',\n `title` varchar(255) NOT NULL COMMENT 'Title (required)',\n `description` text NOT NULL COMMENT 'Description',\n `blocks` json NOT NULL COMMENT 'JSON array of blocks for the page',\n `visible` tinyint(4) NOT NULL COMMENT 'Is page visible in navigation?',\n `weight` int(11) NOT NULL COMMENT 'Order for navigation',\n PRIMARY KEY (`id`) USING BTREE,\n KEY `module_id` (`module_id`),\n KEY `self_id` (`self_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\xac\xe8\x19\x1d\x12\n\x00\x00\x12\n\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00%\x00 \x0020180704080001.crm_fields-data.up.sqlUT\x05\x00\x01\x80Cm8INSERT INTO `crm_field` VALUES ('bool','Boolean value (yes / no)','');\nINSERT INTO `crm_field` VALUES ('email','E-mail input','');\nINSERT INTO `crm_field` VALUES ('enum','Single option picker','');\nINSERT INTO `crm_field` VALUES ('hidden','Hidden value','');\nINSERT INTO `crm_field` VALUES ('stamp','Date/time input','');\nINSERT INTO `crm_field` VALUES ('text','Text input','');\nINSERT INTO `crm_field` VALUES ('textarea','Text input (multi-line)','');\nPK\x07\x08f\x18\x1e\x84\xc5\x01\x00\x00\xc5\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00+\x00 \x0020181109133134.crm_content-ownership.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_content` ADD `user_id` BIGINT UNSIGNED NOT NULL AFTER `module_id`, ADD INDEX (`user_id`);\nPK\x07\x08\xeb!\x81\xc2k\x00\x00\x00k\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00.\x00 \x0020181109193047.crm_fields-related_types.up.sqlUT\x05\x00\x01\x80Cm8INSERT INTO `crm_field` (`field_type`, `field_name`, `field_template`) VALUES ('related', 'Related content', ''), ('related_multi', 'Related content (multiple)', '');PK\x07\x08:.\xfb8\xa6\x00\x00\x00\xa6\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x000\x00 \x0020181125122152.add_multiple_relationships.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `crm_content_links` (\n `content_id` bigint(20) unsigned NOT NULL,\n `column_name` varchar(255) NOT NULL,\n `rel_content_id` bigint(20) unsigned NOT NULL,\n PRIMARY KEY (`content_id`,`column_name`,`rel_content_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;PK\x07\x08\xee\x12\x15 \x05\x01\x00\x00\x05\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00D\x00 \x0020181125132142.add_required_and_visible_to_module_form_fields.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_module_form` ADD `is_required` TINYINT(1) NOT NULL AFTER `is_private`, ADD `is_visible` TINYINT(1) NOT NULL AFTER `is_required`;PK\x07\x08\xa5q c\x91\x00\x00\x00\x91\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x005\x00 \x0020181202163130.fix-crm-module-form-primary-key.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_module_form` DROP PRIMARY KEY, ADD PRIMARY KEY(`module_id`, `place`);\nPK\x07\x08\xd9\xd4i\xe3W\x00\x00\x00W\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x000\x00 \x0020181204123650.add-crm-content-json-field.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_content` ADD `json` json DEFAULT NULL COMMENT 'Content in JSON format.' AFTER `user_id`;\nPK\x07\x08\"\x96\xd6pj\x00\x00\x00j\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x004\x00 \x0020181204155326.add-crm-module-form-json-field.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_module_form` ADD `json` JSON NOT NULL COMMENT 'Options in JSON format.' AFTER `kind`;PK\x07\x08\xb7\x93\xd4\xf6f\x00\x00\x00f\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00+\x00 \x0020181216214630.crm-content-to-record.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_content` RENAME TO `crm_record`;\nALTER TABLE `crm_record` MODIFY COLUMN `json` json DEFAULT NULL COMMENT 'Records in JSON format.';\n\nALTER TABLE `crm_content_column` RENAME TO `crm_record_column`;\nALTER TABLE `crm_record_column` CHANGE COLUMN `content_id` `record_id` bigint(20);\n\nALTER TABLE `crm_content_links` RENAME TO `crm_record_links`;\nALTER TABLE `crm_record_links` CHANGE COLUMN `content_id` `record_id` bigint(20) unsigned;\nALTER TABLE `crm_record_links` CHANGE COLUMN `rel_content_id` `rel_record_id` bigint(20) unsigned;\nPK\x07\x08mA\xa8\x1e&\x02\x00\x00&\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00$\x00 \x0020181217100000.add-charts-tbl.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `crm_chart` (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `name` VARCHAR(64) NOT NULL COMMENT 'The name of the chart',\n `config` JSON NOT NULL COMMENT 'Chart & reporting configuration',\n\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_at` DATETIME DEFAULT NULL,\n `deleted_at` DATETIME DEFAULT NULL,\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xcf\xc6g\xf6\xe4\x01\x00\x00\xe4\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020181224122301.rem-crm_field.up.sqlUT\x05\x00\x01\x80Cm8DROP TABLE `crm_field`;\nPK\x07\x08\xae \xfd2\x18\x00\x00\x00\x18\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00&\x00 \x0020190108100000.add-triggers-tbl.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `crm_trigger` (\n `id` BIGINT(20) UNSIGNED NOT NULL,\n `name` VARCHAR(64) NOT NULL COMMENT 'The name of the trigger',\n `enabled` BOOLEAN NOT NULL COMMENT 'Trigger enabled?',\n `actions` TEXT NOT NULL COMMENT 'All actions that trigger it',\n `source` TEXT NOT NULL COMMENT 'Trigger source',\n `rel_module` BIGINT(20) UNSIGNED NULL COMMENT 'Primary module',\n\n `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updated_at` DATETIME DEFAULT NULL,\n `deleted_at` DATETIME DEFAULT NULL,\n\n PRIMARY KEY (`id`)\n\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08+\xad\xb7\xed\xb8\x02\x00\x00\xb8\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00/\x00 \x0020190110175924.rem-crm-record-json-field.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_record` DROP COLUMN `json`;\nPK\x07\x08\x94#\xb9\x99-\x00\x00\x00-\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x008\x00 \x0020190114072000.cleanup-record-tables-and-multival.up.sqlUT\x05\x00\x01\x80Cm8-- No more links, we'll handle this through ref field on crm_record_value tbl\nDROP TABLE IF EXISTS `crm_record_links`;\n\n-- Not columns, values\nALTER TABLE `crm_record_column` RENAME TO `crm_record_value`;\n\n-- Simplify names\nALTER TABLE `crm_record_value` CHANGE COLUMN `column_name` `name` VARCHAR(64);\nALTER TABLE `crm_record_value` CHANGE COLUMN `column_value` `value` TEXT;\n\n-- Add reference\nALTER TABLE `crm_record_value` ADD COLUMN `ref` BIGINT UNSIGNED DEFAULT 0 NOT NULL;\nALTER TABLE `crm_record_value` ADD COLUMN `deleted_at` datetime DEFAULT NULL;\nALTER TABLE `crm_record_value` ADD COLUMN `place` INT UNSIGNED DEFAULT 0 NOT NULL;\nALTER TABLE `crm_record_value` DROP PRIMARY KEY, ADD PRIMARY KEY(`record_id`, `name`, `place`);\nCREATE INDEX crm_record_value_ref ON crm_record_value (ref);\n\n\n-- We want this as a real field\nALTER TABLE `crm_module_form` ADD COLUMN `is_multi` TINYINT(1) NOT NULL;\n\n-- This will be handled through meta(json) fieldd\nALTER TABLE `crm_module_form` DROP COLUMN `help_text`;\nALTER TABLE `crm_module_form` DROP COLUMN `max_length`;\nALTER TABLE `crm_module_form` DROP COLUMN `default_Value`;\nPK\x07\x08\x04]{\x1fo\x04\x00\x00o\x04\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\x00 \x0020190121132408.record-updated-by.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `crm_record` CHANGE COLUMN `user_id` `owned_by` BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE `crm_record` ADD COLUMN `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE `crm_record` ADD COLUMN `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE `crm_record` ADD COLUMN `deleted_by` BIGINT UNSIGNED NOT NULL DEFAULT 0;\nUPDATE crm_record SET created_by = owned_by;\nUPDATE crm_record SET updated_by = owned_by WHERE updated_at IS NOT NULL;\nUPDATE crm_record SET deleted_by = owned_by WHERE deleted_at IS NOT NULL;\nPK\x07\x08h\xe2\xeb\n!\x02\x00\x00!\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00 \x0020190227090642.attachment.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE crm_attachment (\n id BIGINT UNSIGNED NOT NULL,\n rel_owner BIGINT UNSIGNED NOT NULL,\n\n kind VARCHAR(32) NOT NULL,\n\n url VARCHAR(512),\n preview_url VARCHAR(512),\n\n size INT UNSIGNED,\n mimetype VARCHAR(255),\n name TEXT,\n\n meta JSON,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- page attachments will be referenced via page-block meta data\n-- module/record attachment will be referenced via crm_record_value\nPK\x07\x08\xce\xde?\x08\xb3\x02\x00\x00\xb3\x02\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` text NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x089S\x05%x\x01\x00\x00x\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x00 \x00new.shUT\x05\x00\x01\x80Cm8#!/bin/bash\ntouch $(date +%Y%m%d%H%M%S).up.sql\nPK\x07\x08\xc1h\xf1\xfb/\x00\x00\x00/\x00\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xac\xe8\x19\x1d\x12\n\x00\x00\x12\n\x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(f\x18\x1e\x84\xc5\x01\x00\x00\xc5\x01\x00\x00%\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81c\n\x00\x0020180704080001.crm_fields-data.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xeb!\x81\xc2k\x00\x00\x00k\x00\x00\x00+\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x84\x0c\x00\x0020181109133134.crm_content-ownership.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(:.\xfb8\xa6\x00\x00\x00\xa6\x00\x00\x00.\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81Q\x0d\x00\x0020181109193047.crm_fields-related_types.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xee\x12\x15 \x05\x01\x00\x00\x05\x01\x00\x000\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\\\x0e\x00\x0020181125122152.add_multiple_relationships.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xa5q c\x91\x00\x00\x00\x91\x00\x00\x00D\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc8\x0f\x00\x0020181125132142.add_required_and_visible_to_module_form_fields.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xd9\xd4i\xe3W\x00\x00\x00W\x00\x00\x005\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd4\x10\x00\x0020181202163130.fix-crm-module-form-primary-key.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\"\x96\xd6pj\x00\x00\x00j\x00\x00\x000\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x97\x11\x00\x0020181204123650.add-crm-content-json-field.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xb7\x93\xd4\xf6f\x00\x00\x00f\x00\x00\x004\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81h\x12\x00\x0020181204155326.add-crm-module-form-json-field.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(mA\xa8\x1e&\x02\x00\x00&\x02\x00\x00+\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x819\x13\x00\x0020181216214630.crm-content-to-record.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xcf\xc6g\xf6\xe4\x01\x00\x00\xe4\x01\x00\x00$\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc1\x15\x00\x0020181217100000.add-charts-tbl.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xae \xfd2\x18\x00\x00\x00\x18\x00\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x18\x00\x0020181224122301.rem-crm_field.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(+\xad\xb7\xed\xb8\x02\x00\x00\xb8\x02\x00\x00&\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81r\x18\x00\x0020190108100000.add-triggers-tbl.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x94#\xb9\x99-\x00\x00\x00-\x00\x00\x00/\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x87\x1b\x00\x0020190110175924.rem-crm-record-json-field.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x04]{\x1fo\x04\x00\x00o\x04\x00\x008\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x1a\x1c\x00\x0020190114072000.cleanup-record-tables-and-multival.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(h\xe2\xeb\n!\x02\x00\x00!\x02\x00\x00'\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf8 \x00\x0020190121132408.record-updated-by.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xce\xde?\x08\xb3\x02\x00\x00\xb3\x02\x00\x00 \x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81w#\x00\x0020190227090642.attachment.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(9S\x05%x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x81&\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xc1h\xf1\xfb/\x00\x00\x00/\x00\x00\x00\x06\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xed\x81>(\x00\x00new.shUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x13\x00\x13\x00\x14\x07\x00\x00\xaa(\x00\x00\x00\x00" } func init() { diff --git a/crm/db/schema/mysql/20190227090642.attachment.up.sql b/crm/db/schema/mysql/20190227090642.attachment.up.sql new file mode 100644 index 000000000..e117e2a15 --- /dev/null +++ b/crm/db/schema/mysql/20190227090642.attachment.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE crm_attachment ( + id BIGINT UNSIGNED NOT NULL, + rel_owner BIGINT UNSIGNED NOT NULL, + + kind VARCHAR(32) NOT NULL, + + url VARCHAR(512), + preview_url VARCHAR(512), + + size INT UNSIGNED, + mimetype VARCHAR(255), + name TEXT, + + meta JSON, + + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NULL, + deleted_at DATETIME NULL, + + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- page attachments will be referenced via page-block meta data +-- module/record attachment will be referenced via crm_record_value diff --git a/crm/repository/attachment.go b/crm/repository/attachment.go new file mode 100644 index 000000000..2522587cc --- /dev/null +++ b/crm/repository/attachment.go @@ -0,0 +1,161 @@ +package repository + +import ( + "context" + "time" + + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + "github.com/titpetric/factory" + sq "gopkg.in/Masterminds/squirrel.v1" + + "github.com/crusttech/crust/crm/types" +) + +type ( + AttachmentRepository interface { + With(ctx context.Context, db *factory.DB) AttachmentRepository + + Find(filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) + FindByID(id uint64) (*types.Attachment, error) + FindByIDs(IDs ...uint64) (types.AttachmentSet, error) + Create(mod *types.Attachment) (*types.Attachment, error) + DeleteByID(id uint64) error + } + + attachment struct { + *repository + } +) + +const ( + sqlAttachmentColumns = ` + a.id, a.rel_owner, a.kind, + a.url, a.preview_url, + a.name, + a.meta, + a.created_at, a.updated_at, a.deleted_at + ` + sqlAttachmentScope = "deleted_at IS NULL" + + sqlAttachmentByID = `SELECT ` + sqlAttachmentColumns + + ` FROM crm_attachment AS a WHERE id = ? AND ` + sqlAttachmentScope + + sqlAttachmentsByIDs = `SELECT ` + sqlAttachmentColumns + + ` FROM crm_attachment AS a WHERE id IN (?) AND ` + sqlAttachmentScope +) + +func Attachment(ctx context.Context, db *factory.DB) AttachmentRepository { + return (&attachment{}).With(ctx, db) +} + +func (r *attachment) With(ctx context.Context, db *factory.DB) AttachmentRepository { + return &attachment{ + repository: r.repository.With(ctx, db), + } +} + +func (r *attachment) FindByID(id uint64) (*types.Attachment, error) { + mod := &types.Attachment{} + + return mod, r.db().Get(mod, sqlAttachmentByID, id) +} + +func (r *attachment) FindByIDs(IDs ...uint64) (rval types.AttachmentSet, err error) { + rval = make([]*types.Attachment, 0) + + if len(IDs) == 0 { + return + } + + if sql, args, err := sqlx.In(sqlAttachmentsByIDs, IDs); err != nil { + return nil, err + } else { + return rval, r.db().Select(&rval, sql, args...) + } +} + +func (r *attachment) Find(filter types.AttachmentFilter) (set types.AttachmentSet, f types.AttachmentFilter, err error) { + f = filter + if f.PerPage > 100 { + f.PerPage = 100 + } else if f.PerPage == 0 { + f.PerPage = 50 + } + + set = types.AttachmentSet{} + + query := sq.Select().From("crm_attachment AS a").Where(sq.Eq{"a.kind": f.Kind}) + + switch f.Kind { + case types.PageAttachment: + // @todo implement filtering by page + err = errors.New("filtering by page not implemented") + return + case types.RecordAttachment: + query = query. + Join("crm_record_value AS v ON (v.ref = a.id)") + + if f.ModuleID > 0 { + query = query. + Join("crm_record AS r ON (r.id = v.record_id)"). + Where(sq.Eq{"r.module_id": f.ModuleID}) + } + + if f.RecordID > 0 { + query = query.Where(sq.Eq{"v.record_id": f.RecordID}) + } + + if f.FieldName != "" { + query = query.Where(sq.Eq{"v.name": f.FieldName}) + } + } + + if f.Filter != "" { + err = errors.New("filtering by filter not implemented") + return + } + + // Assemble SQL for counting (includes only where) + count := query.Column("COUNT(*)") + if sqlSelect, argsSelect, err := count.ToSql(); err != nil { + return set, f, err + } else { + // Execute count query. + if err := r.db().Get(&f.Count, sqlSelect, argsSelect...); err != nil { + return set, f, err + } + + // Return empty response if count of records is zero. + if f.Count == 0 { + return set, f, nil + } + } + + // Assemble SQL for fetching attachments (where + sorting + paging)... + query = query. + Column(sqlAttachmentColumns). + Limit(uint64(f.PerPage)). + Offset(uint64(f.Page * f.PerPage)) + + if sqlSelect, argsSelect, err := query.ToSql(); err != nil { + return set, f, err + } else { + return set, f, r.db().Select(&set, sqlSelect, argsSelect...) + } +} + +func (r *attachment) Create(mod *types.Attachment) (*types.Attachment, error) { + if mod.ID == 0 { + mod.ID = factory.Sonyflake.NextID() + } + + mod.CreatedAt = time.Now() + + return mod, r.db().Insert("crm_attachment", mod) +} + +func (r *attachment) DeleteByID(id uint64) error { + _, err := r.db().Exec("UPDATE crm_attachment SET deleted_at = NOW() WHERE id = ?", id) + return err +} diff --git a/crm/rest/attachment.go b/crm/rest/attachment.go new file mode 100644 index 000000000..2700f37fa --- /dev/null +++ b/crm/rest/attachment.go @@ -0,0 +1,53 @@ +package rest + +import ( + "context" + + "github.com/crusttech/crust/crm/rest/request" + "github.com/crusttech/crust/crm/service" + "github.com/crusttech/crust/crm/types" + + "github.com/pkg/errors" +) + +var _ = errors.Wrap + +type Attachment struct { + attachment service.AttachmentService +} + +func (Attachment) New() *Attachment { + return &Attachment{attachment: service.DefaultAttachment} +} + +// Attachments returns list of all files attached to records +func (ctrl *Attachment) List(ctx context.Context, r *request.AttachmentList) (interface{}, error) { + f := types.AttachmentFilter{ + Kind: types.RecordAttachment, + ModuleID: r.ModuleID, + RecordID: r.RecordID, + FieldName: r.FieldName, + // Filter: r.Filter, + PerPage: r.PerPage, + Page: r.Page, + // Sort: r.Sort, + } + + return makeRecordAttachmentSetPayload(ctrl.attachment.Find(f)) +} + +func (ctrl *Attachment) Details(ctx context.Context, r *request.AttachmentDetails) (interface{}, error) { + if a, err := ctrl.attachment.FindByID(r.AttachmentID); err != nil { + return nil, err + } else { + return makeAttachmentPayload(a), nil + } +} + +func (ctrl *Attachment) Original(ctx context.Context, r *request.AttachmentOriginal) (interface{}, error) { + return loadAttachedFile(ctrl.attachment, r.AttachmentID, false, r.Download) +} + +func (ctrl *Attachment) Preview(ctx context.Context, r *request.AttachmentPreview) (interface{}, error) { + return loadAttachedFile(ctrl.attachment, r.AttachmentID, true, false) +} diff --git a/crm/rest/attachment_custom.go b/crm/rest/attachment_custom.go new file mode 100644 index 000000000..d7875fe43 --- /dev/null +++ b/crm/rest/attachment_custom.go @@ -0,0 +1,119 @@ +package rest + +import ( + "fmt" + "io" + "net/url" + "time" + + "github.com/davecgh/go-spew/spew" + + "github.com/crusttech/crust/crm/rest/handlers" + "github.com/crusttech/crust/crm/service" + "github.com/crusttech/crust/crm/types" +) + +type ( + attachmentPayload struct { + ID uint64 `json:"attachmentID,string"` + OwnerID uint64 `json:"ownerID,string"` + Url string `json:"url"` + PreviewUrl string `json:"previewUrl,omitempty"` + Meta interface{} `json:"meta"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + } + + file struct { + *types.Attachment + content io.ReadSeeker + download bool + } +) + +func (f *file) Download() bool { + return f.download +} + +func (f *file) Name() string { + return f.Attachment.Name +} + +func (f *file) ModTime() time.Time { + return f.Attachment.CreatedAt +} + +func (f *file) Content() io.ReadSeeker { + return f.content +} + +func (f *file) Valid() bool { + return f.content != nil +} + +func loadAttachedFile(svc service.AttachmentService, ID uint64, preview, download bool) (handlers.Downloadable, error) { + rval := &file{download: download} + + if att, err := svc.FindByID(ID); err != nil { + return nil, err + } else { + rval.Attachment = att + if preview { + spew.Dump(att) + rval.content, err = svc.OpenPreview(att) + } else { + rval.content, err = svc.OpenOriginal(att) + } + + if err != nil { + return nil, err + } + } + + return rval, nil +} + +func makeAttachmentPayload(a *types.Attachment) *attachmentPayload { + if a == nil { + return nil + } + + var preview string + var baseURL = fmt.Sprintf("/attachment/%s/%d/", a.Kind, a.ID) + + if a.Meta.Preview != nil { + var ext = a.Meta.Preview.Extension + if ext == "" { + ext = "jpg" + } + + preview = baseURL + fmt.Sprintf("preview.%s", ext) + } + + return &attachmentPayload{ + ID: a.ID, + OwnerID: a.OwnerID, + Url: baseURL + fmt.Sprintf("original/%s", url.PathEscape(a.Name)), + PreviewUrl: preview, + Meta: a.Meta, + Name: a.Name, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + } +} + +func makeRecordAttachmentSetPayload(aa types.AttachmentSet, meta types.AttachmentFilter, err error) (map[string]interface{}, error) { + if err != nil { + return nil, err + } + + pp := make([]*attachmentPayload, len(aa)) + for i := range aa { + pp[i] = makeAttachmentPayload(aa[i]) + } + + rval := map[string]interface{}{"meta": meta, "attachments": pp} + + return rval, err +} diff --git a/crm/rest/handlers/attachment.go b/crm/rest/handlers/attachment.go new file mode 100644 index 000000000..6090c44f4 --- /dev/null +++ b/crm/rest/handlers/attachment.go @@ -0,0 +1,87 @@ +package handlers + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `attachment.go`, `attachment.util.go` or `attachment_test.go` to + implement your API calls, helper functions and tests. The file `attachment.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "context" + "github.com/go-chi/chi" + "net/http" + + "github.com/titpetric/factory/resputil" + + "github.com/crusttech/crust/crm/rest/request" +) + +// Internal API interface +type AttachmentAPI interface { + List(context.Context, *request.AttachmentList) (interface{}, error) + Details(context.Context, *request.AttachmentDetails) (interface{}, error) + Original(context.Context, *request.AttachmentOriginal) (interface{}, error) + Preview(context.Context, *request.AttachmentPreview) (interface{}, error) +} + +// HTTP API interface +type Attachment struct { + List func(http.ResponseWriter, *http.Request) + Details func(http.ResponseWriter, *http.Request) + Original func(http.ResponseWriter, *http.Request) + Preview func(http.ResponseWriter, *http.Request) +} + +func NewAttachment(ah AttachmentAPI) *Attachment { + return &Attachment{ + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentList() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return ah.List(r.Context(), params) + }) + }, + Details: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentDetails() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return ah.Details(r.Context(), params) + }) + }, + Original: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentOriginal() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return ah.Original(r.Context(), params) + }) + }, + Preview: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentPreview() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return ah.Preview(r.Context(), params) + }) + }, + } +} + +func (ah *Attachment) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Route("/attachment/{kind}", func(r chi.Router) { + r.Get("/", ah.List) + r.Get("/{attachmentID}", ah.Details) + r.Get("/{attachmentID}/original/{name}", ah.Original) + r.Get("/{attachmentID}/preview.{ext}", ah.Preview) + }) + }) +} diff --git a/crm/rest/handlers/attachment_custom.go b/crm/rest/handlers/attachment_custom.go new file mode 100644 index 000000000..2e6c49e39 --- /dev/null +++ b/crm/rest/handlers/attachment_custom.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "io" + "net/http" + "net/url" + "time" + + "github.com/crusttech/crust/crm/rest/request" +) + +type Downloadable interface { + Name() string + Download() bool + ModTime() time.Time + Content() io.ReadSeeker + Valid() bool +} + +func NewAttachmentDownloadable(ctrl AttachmentAPI) *Attachment { + h := NewAttachment(ctrl) + h.Original = func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentOriginal() + params.Fill(r) + + f, err := ctrl.Original(r.Context(), params) + serveFile(f, err, w, r) + } + + h.Preview = func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentPreview() + params.Fill(r) + + f, err := ctrl.Preview(r.Context(), params) + serveFile(f, err, w, r) + } + + return h +} + +func serveFile(f interface{}, err error, w http.ResponseWriter, r *http.Request) { + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else if dl, ok := f.(Downloadable); ok { + if !dl.Valid() { + w.WriteHeader(http.StatusNotFound) + } else { + if dl.Download() { + w.Header().Add("Content-Disposition", "attachment; filename="+url.QueryEscape(dl.Name())) + } else { + w.Header().Add("Content-Disposition", "inline; filename="+url.QueryEscape(dl.Name())) + } + + http.ServeContent(w, r, dl.Name(), dl.ModTime(), dl.Content()) + } + } else { + http.Error(w, "Got incompatible type from controller", http.StatusInternalServerError) + } +} diff --git a/crm/rest/handlers/module.go b/crm/rest/handlers/module.go index b21231dad..72e47e53e 100644 --- a/crm/rest/handlers/module.go +++ b/crm/rest/handlers/module.go @@ -32,27 +32,15 @@ type ModuleAPI interface { Read(context.Context, *request.ModuleRead) (interface{}, error) Update(context.Context, *request.ModuleUpdate) (interface{}, error) Delete(context.Context, *request.ModuleDelete) (interface{}, error) - RecordReport(context.Context, *request.ModuleRecordReport) (interface{}, error) - RecordList(context.Context, *request.ModuleRecordList) (interface{}, error) - RecordCreate(context.Context, *request.ModuleRecordCreate) (interface{}, error) - RecordRead(context.Context, *request.ModuleRecordRead) (interface{}, error) - RecordUpdate(context.Context, *request.ModuleRecordUpdate) (interface{}, error) - RecordDelete(context.Context, *request.ModuleRecordDelete) (interface{}, error) } // HTTP API interface type Module struct { - List func(http.ResponseWriter, *http.Request) - Create func(http.ResponseWriter, *http.Request) - Read func(http.ResponseWriter, *http.Request) - Update func(http.ResponseWriter, *http.Request) - Delete func(http.ResponseWriter, *http.Request) - RecordReport func(http.ResponseWriter, *http.Request) - RecordList func(http.ResponseWriter, *http.Request) - RecordCreate func(http.ResponseWriter, *http.Request) - RecordRead func(http.ResponseWriter, *http.Request) - RecordUpdate func(http.ResponseWriter, *http.Request) - RecordDelete func(http.ResponseWriter, *http.Request) + List func(http.ResponseWriter, *http.Request) + Create func(http.ResponseWriter, *http.Request) + Read func(http.ResponseWriter, *http.Request) + Update func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) } func NewModule(mh ModuleAPI) *Module { @@ -92,48 +80,6 @@ func NewModule(mh ModuleAPI) *Module { return mh.Delete(r.Context(), params) }) }, - RecordReport: func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - params := request.NewModuleRecordReport() - resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.RecordReport(r.Context(), params) - }) - }, - RecordList: func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - params := request.NewModuleRecordList() - resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.RecordList(r.Context(), params) - }) - }, - RecordCreate: func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - params := request.NewModuleRecordCreate() - resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.RecordCreate(r.Context(), params) - }) - }, - RecordRead: func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - params := request.NewModuleRecordRead() - resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.RecordRead(r.Context(), params) - }) - }, - RecordUpdate: func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - params := request.NewModuleRecordUpdate() - resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.RecordUpdate(r.Context(), params) - }) - }, - RecordDelete: func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - params := request.NewModuleRecordDelete() - resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.RecordDelete(r.Context(), params) - }) - }, } } @@ -146,12 +92,6 @@ func (mh *Module) MountRoutes(r chi.Router, middlewares ...func(http.Handler) ht r.Get("/{moduleID}", mh.Read) r.Post("/{moduleID}", mh.Update) r.Delete("/{moduleID}", mh.Delete) - r.Get("/{moduleID}/report", mh.RecordReport) - r.Get("/{moduleID}/record", mh.RecordList) - r.Post("/{moduleID}/record", mh.RecordCreate) - r.Get("/{moduleID}/record/{recordID}", mh.RecordRead) - r.Post("/{moduleID}/record/{recordID}", mh.RecordUpdate) - r.Delete("/{moduleID}/record/{recordID}", mh.RecordDelete) }) }) } diff --git a/crm/rest/handlers/page.go b/crm/rest/handlers/page.go index e8a642101..15235402e 100644 --- a/crm/rest/handlers/page.go +++ b/crm/rest/handlers/page.go @@ -34,6 +34,7 @@ type PageAPI interface { Update(context.Context, *request.PageUpdate) (interface{}, error) Reorder(context.Context, *request.PageReorder) (interface{}, error) Delete(context.Context, *request.PageDelete) (interface{}, error) + Upload(context.Context, *request.PageUpload) (interface{}, error) } // HTTP API interface @@ -45,6 +46,7 @@ type Page struct { Update func(http.ResponseWriter, *http.Request) Reorder func(http.ResponseWriter, *http.Request) Delete func(http.ResponseWriter, *http.Request) + Upload func(http.ResponseWriter, *http.Request) } func NewPage(ph PageAPI) *Page { @@ -98,6 +100,13 @@ func NewPage(ph PageAPI) *Page { return ph.Delete(r.Context(), params) }) }, + Upload: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewPageUpload() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return ph.Upload(r.Context(), params) + }) + }, } } @@ -112,6 +121,7 @@ func (ph *Page) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http r.Post("/{pageID}", ph.Update) r.Post("/{selfID}/reorder", ph.Reorder) r.Delete("/{pageID}", ph.Delete) + r.Post("/{pageID}/attachment", ph.Upload) }) }) } diff --git a/crm/rest/handlers/record.go b/crm/rest/handlers/record.go new file mode 100644 index 000000000..d03315e80 --- /dev/null +++ b/crm/rest/handlers/record.go @@ -0,0 +1,117 @@ +package handlers + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `record.go`, `record.util.go` or `record_test.go` to + implement your API calls, helper functions and tests. The file `record.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "context" + "github.com/go-chi/chi" + "net/http" + + "github.com/titpetric/factory/resputil" + + "github.com/crusttech/crust/crm/rest/request" +) + +// Internal API interface +type RecordAPI interface { + Report(context.Context, *request.RecordReport) (interface{}, error) + List(context.Context, *request.RecordList) (interface{}, error) + Create(context.Context, *request.RecordCreate) (interface{}, error) + Read(context.Context, *request.RecordRead) (interface{}, error) + Update(context.Context, *request.RecordUpdate) (interface{}, error) + Delete(context.Context, *request.RecordDelete) (interface{}, error) + Upload(context.Context, *request.RecordUpload) (interface{}, error) +} + +// HTTP API interface +type Record struct { + Report func(http.ResponseWriter, *http.Request) + List func(http.ResponseWriter, *http.Request) + Create func(http.ResponseWriter, *http.Request) + Read func(http.ResponseWriter, *http.Request) + Update func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) + Upload func(http.ResponseWriter, *http.Request) +} + +func NewRecord(rh RecordAPI) *Record { + return &Record{ + Report: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordReport() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return rh.Report(r.Context(), params) + }) + }, + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordList() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return rh.List(r.Context(), params) + }) + }, + Create: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordCreate() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return rh.Create(r.Context(), params) + }) + }, + Read: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordRead() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return rh.Read(r.Context(), params) + }) + }, + Update: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordUpdate() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return rh.Update(r.Context(), params) + }) + }, + Delete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordDelete() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return rh.Delete(r.Context(), params) + }) + }, + Upload: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordUpload() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return rh.Upload(r.Context(), params) + }) + }, + } +} + +func (rh *Record) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Route("/module/{moduleID}", func(r chi.Router) { + r.Get("/report", rh.Report) + r.Get("/record", rh.List) + r.Post("/record", rh.Create) + r.Get("/record/{recordID}", rh.Read) + r.Post("/record/{recordID}", rh.Update) + r.Delete("/record/{recordID}", rh.Delete) + r.Post("/record/{recordID}/{fieldName}/attachment", rh.Upload) + }) + }) +} diff --git a/crm/rest/module.go b/crm/rest/module.go index ecdb62e4d..68a866a29 100644 --- a/crm/rest/module.go +++ b/crm/rest/module.go @@ -24,60 +24,33 @@ func (Module) New() *Module { } } -func (s *Module) List(ctx context.Context, r *request.ModuleList) (interface{}, error) { - return s.module.With(ctx).Find() +func (ctrl *Module) List(ctx context.Context, r *request.ModuleList) (interface{}, error) { + return ctrl.module.With(ctx).Find() } -func (s *Module) Read(ctx context.Context, r *request.ModuleRead) (interface{}, error) { - return s.module.With(ctx).FindByID(r.ModuleID) +func (ctrl *Module) Read(ctx context.Context, r *request.ModuleRead) (interface{}, error) { + return ctrl.module.With(ctx).FindByID(r.ModuleID) } -func (s *Module) Delete(ctx context.Context, r *request.ModuleDelete) (interface{}, error) { - return resputil.OK(), s.module.With(ctx).DeleteByID(r.ModuleID) +func (ctrl *Module) Delete(ctx context.Context, r *request.ModuleDelete) (interface{}, error) { + return resputil.OK(), ctrl.module.With(ctx).DeleteByID(r.ModuleID) } -func (s *Module) Create(ctx context.Context, r *request.ModuleCreate) (interface{}, error) { +func (ctrl *Module) Create(ctx context.Context, r *request.ModuleCreate) (interface{}, error) { item := &types.Module{ Name: r.Name, Fields: r.Fields, Meta: r.Meta, } - return s.module.With(ctx).Create(item) + return ctrl.module.With(ctx).Create(item) } -func (s *Module) Update(ctx context.Context, r *request.ModuleUpdate) (interface{}, error) { +func (ctrl *Module) Update(ctx context.Context, r *request.ModuleUpdate) (interface{}, error) { item := &types.Module{ ID: r.ModuleID, Name: r.Name, Fields: r.Fields, Meta: r.Meta, } - return s.module.With(ctx).Update(item) -} - -func (s *Module) RecordReport(ctx context.Context, r *request.ModuleRecordReport) (interface{}, error) { - return s.record.With(ctx).Report(r.ModuleID, r.Metrics, r.Dimensions, r.Filter) -} - -func (s *Module) RecordList(ctx context.Context, r *request.ModuleRecordList) (interface{}, error) { - return s.record.With(ctx).Find(r.ModuleID, r.Filter, r.Sort, r.Page, r.PerPage) -} - -func (s *Module) RecordRead(ctx context.Context, r *request.ModuleRecordRead) (interface{}, error) { - return s.record.With(ctx).FindByID(r.RecordID) -} - -func (s *Module) RecordCreate(ctx context.Context, r *request.ModuleRecordCreate) (interface{}, error) { - return s.record.With(ctx).Create(&types.Record{ModuleID: r.ModuleID, Values: r.Values}) -} - -func (s *Module) RecordUpdate(ctx context.Context, r *request.ModuleRecordUpdate) (interface{}, error) { - return s.record.With(ctx).Update(&types.Record{ - ID: r.RecordID, - ModuleID: r.ModuleID, - Values: r.Values}) -} - -func (s *Module) RecordDelete(ctx context.Context, r *request.ModuleRecordDelete) (interface{}, error) { - return resputil.OK(), s.record.With(ctx).DeleteByID(r.RecordID) + return ctrl.module.With(ctx).Update(item) } diff --git a/crm/rest/page.go b/crm/rest/page.go index fedee93b1..60a5ae602 100644 --- a/crm/rest/page.go +++ b/crm/rest/page.go @@ -13,13 +13,15 @@ import ( type ( Page struct { - page service.PageService + page service.PageService + attachment service.AttachmentService } ) func (Page) New() *Page { return &Page{ - page: service.DefaultPage, + page: service.DefaultPage, + attachment: service.DefaultAttachment, } } @@ -71,3 +73,26 @@ func (ctrl *Page) Update(ctx context.Context, r *request.PageUpdate) (interface{ func (ctrl *Page) Delete(ctx context.Context, r *request.PageDelete) (interface{}, error) { return resputil.OK(), ctrl.page.With(ctx).DeleteByID(r.PageID) } + +func (ctrl *Page) Upload(ctx context.Context, r *request.PageUpload) (interface{}, error) { + // @todo [SECURITY] check if attachments can be added to this page + file, err := r.Upload.Open() + if err != nil { + return nil, err + } + + defer file.Close() + + a, err := ctrl.attachment.With(ctx).CreatePageAttachment( + r.Upload.Filename, + r.Upload.Size, + file, + r.PageID, + ) + + if err != nil { + return nil, err + } + + return makeAttachmentPayload(a), nil +} diff --git a/crm/rest/pageattachment.go_ b/crm/rest/pageattachment.go_ new file mode 100644 index 000000000..bd5789bfd --- /dev/null +++ b/crm/rest/pageattachment.go_ @@ -0,0 +1,67 @@ +package rest + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + + "github.com/crusttech/crust/crm/rest/request" + "github.com/crusttech/crust/crm/service" +) + +var _ = errors.Wrap + +type PageAttachment struct { + att service.AttachmentService +} + +func (PageAttachment) New() *PageAttachment { + return &PageAttachment{att: service.DefaultAttachment} +} + +func (ctrl *PageAttachment) Upload(ctx context.Context, r *request.PageAttachmentUpload) (interface{}, error) { + // @todo [SECURITY] check if attachments can be added to this page + file, err := r.Upload.Open() + if err != nil { + return nil, err + } + + defer file.Close() + + a, err := ctrl.att.With(ctx).CreatePageAttachment( + r.Upload.Filename, + r.Upload.Size, + file, + r.PageID, + ) + + if err != nil { + return nil, err + } + + baseURL := fmt.Sprintf("/page/%d/attachment", r.PageID) + return makeAttachmentPayload(baseURL, a), nil +} + +func (ctrl *PageAttachment) Details(ctx context.Context, r *request.PageAttachmentDetails) (interface{}, error) { + // @todo [SECURITY] check if page can be accessed + // @todo [SECURITY] test any of the page blocks contain this attachment, return 404 if not + if a, err := ctrl.att.FindByID(r.AttachmentID); err != nil { + return nil, err + } else { + return makePageAttachmentPayload(r.PageID, a), nil + } +} + +func (ctrl *PageAttachment) Original(ctx context.Context, r *request.PageAttachmentOriginal) (interface{}, error) { + // @todo [SECURITY] check if page can be accessed + // @todo [SECURITY] test any of the page blocks contain this attachment, return 404 if not + return loadAttachedFile(ctrl.att, r.AttachmentID, false, r.Download) +} + +func (ctrl *PageAttachment) Preview(ctx context.Context, r *request.PageAttachmentPreview) (interface{}, error) { + // @todo [SECURITY] check if page can be accessed + // @todo [SECURITY] test any of the page blocks contain this attachment, return 404 if not + return loadAttachedFile(ctrl.att, r.AttachmentID, true, false) +} diff --git a/crm/rest/record.go b/crm/rest/record.go new file mode 100644 index 000000000..7bf518889 --- /dev/null +++ b/crm/rest/record.go @@ -0,0 +1,79 @@ +package rest + +import ( + "context" + + "github.com/titpetric/factory/resputil" + + "github.com/crusttech/crust/crm/rest/request" + "github.com/crusttech/crust/crm/service" + "github.com/crusttech/crust/crm/types" + + "github.com/pkg/errors" +) + +var _ = errors.Wrap + +type Record struct { + record service.RecordService + attachment service.AttachmentService +} + +func (Record) New() *Record { + return &Record{ + record: service.DefaultRecord, + attachment: service.DefaultAttachment, + } +} + +func (ctrl *Record) Report(ctx context.Context, r *request.RecordReport) (interface{}, error) { + return ctrl.record.With(ctx).Report(r.ModuleID, r.Metrics, r.Dimensions, r.Filter) +} + +func (ctrl *Record) List(ctx context.Context, r *request.RecordList) (interface{}, error) { + return ctrl.record.With(ctx).Find(r.ModuleID, r.Filter, r.Sort, r.Page, r.PerPage) +} + +func (ctrl *Record) Read(ctx context.Context, r *request.RecordRead) (interface{}, error) { + return ctrl.record.With(ctx).FindByID(r.RecordID) +} + +func (ctrl *Record) Create(ctx context.Context, r *request.RecordCreate) (interface{}, error) { + return ctrl.record.With(ctx).Create(&types.Record{ModuleID: r.ModuleID, Values: r.Values}) +} + +func (ctrl *Record) Update(ctx context.Context, r *request.RecordUpdate) (interface{}, error) { + return ctrl.record.With(ctx).Update(&types.Record{ + ID: r.RecordID, + ModuleID: r.ModuleID, + Values: r.Values}) +} + +func (ctrl *Record) Delete(ctx context.Context, r *request.RecordDelete) (interface{}, error) { + return resputil.OK(), ctrl.record.With(ctx).DeleteByID(r.RecordID) +} + +func (ctrl *Record) Upload(ctx context.Context, r *request.RecordUpload) (interface{}, error) { + // @todo [SECURITY] check if attachments can be added to this page + file, err := r.Upload.Open() + if err != nil { + return nil, err + } + + defer file.Close() + + a, err := ctrl.attachment.With(ctx).CreateRecordAttachment( + r.Upload.Filename, + r.Upload.Size, + file, + r.ModuleID, + r.RecordID, + r.FieldName, + ) + + if err != nil { + return nil, err + } + + return makeAttachmentPayload(a), nil +} diff --git a/crm/rest/recordattachment.go_ b/crm/rest/recordattachment.go_ new file mode 100644 index 000000000..3db18a3e6 --- /dev/null +++ b/crm/rest/recordattachment.go_ @@ -0,0 +1,69 @@ +package rest + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + + "github.com/crusttech/crust/crm/rest/request" + "github.com/crusttech/crust/crm/service" +) + +var _ = errors.Wrap + +type RecordAttachment struct { + att service.AttachmentService +} + +func (RecordAttachment) New() *RecordAttachment { + return &RecordAttachment{att: service.DefaultAttachment} +} + +func (ctrl *RecordAttachment) Upload(ctx context.Context, r *request.RecordAttachmentUpload) (interface{}, error) { + // @todo [SECURITY] check if attachments can be added to this page + file, err := r.Upload.Open() + if err != nil { + return nil, err + } + + defer file.Close() + + a, err := ctrl.att.With(ctx).CreateRecordAttachment( + r.Upload.Filename, + r.Upload.Size, + file, + r.ModuleID, + r.RecordID, + r.FieldName, + ) + + if err != nil { + return nil, err + } + + baseURL := fmt.Sprintf("/module/%d/record/%d/attachment/%s/", r.ModuleID, r.RecordID, r.FieldName) + return makeAttachmentPayload(baseURL, a), nil +} + +func (ctrl *RecordAttachment) Details(ctx context.Context, r *request.RecordAttachmentDetails) (interface{}, error) { + // @todo [security] check if record can be accessed + // @todo [SECURITY] test if module/record/field has this attachment, return 404 if not + if a, err := ctrl.att.FindByID(r.AttachmentID); err != nil { + return nil, err + } else { + return makeRecordAttachmentPayload(r.ModuleID, r.RecordID, r.FieldName, a), nil + } +} + +func (ctrl *RecordAttachment) Original(ctx context.Context, r *request.RecordAttachmentOriginal) (interface{}, error) { + // @todo [security] check if record can be accessed + // @todo [SECURITY] test if module/record/field has this attachment, return 404 if not + return loadAttachedFile(ctrl.att, r.AttachmentID, false, r.Download) +} + +func (ctrl *RecordAttachment) Preview(ctx context.Context, r *request.RecordAttachmentPreview) (interface{}, error) { + // @todo [security] check if record can be accessed + // @todo [SECURITY] test if module/record/field has this attachment, return 404 if not + return loadAttachedFile(ctrl.att, r.AttachmentID, true, false) +} diff --git a/crm/rest/request/attachment.go b/crm/rest/request/attachment.go new file mode 100644 index 000000000..b4e04341c --- /dev/null +++ b/crm/rest/request/attachment.go @@ -0,0 +1,247 @@ +package request + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `attachment.go`, `attachment.util.go` or `attachment_test.go` to + implement your API calls, helper functions and tests. The file `attachment.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "encoding/json" + "io" + "mime/multipart" + "net/http" + "strings" + + "github.com/go-chi/chi" + "github.com/pkg/errors" +) + +var _ = chi.URLParam +var _ = multipart.FileHeader{} + +// Attachment list request parameters +type AttachmentList struct { + PageID uint64 `json:",string"` + ModuleID uint64 `json:",string"` + RecordID uint64 `json:",string"` + FieldName string + Page uint + PerPage uint + Kind string +} + +func NewAttachmentList() *AttachmentList { + return &AttachmentList{} +} + +func (aReq *AttachmentList) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(aReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := get["pageID"]; ok { + + aReq.PageID = parseUInt64(val) + } + if val, ok := get["moduleID"]; ok { + + aReq.ModuleID = parseUInt64(val) + } + if val, ok := get["recordID"]; ok { + + aReq.RecordID = parseUInt64(val) + } + if val, ok := get["fieldName"]; ok { + + aReq.FieldName = val + } + if val, ok := get["page"]; ok { + + aReq.Page = parseUint(val) + } + if val, ok := get["perPage"]; ok { + + aReq.PerPage = parseUint(val) + } + aReq.Kind = chi.URLParam(r, "kind") + + return err +} + +var _ RequestFiller = NewAttachmentList() + +// Attachment details request parameters +type AttachmentDetails struct { + AttachmentID uint64 `json:",string"` + Kind string +} + +func NewAttachmentDetails() *AttachmentDetails { + return &AttachmentDetails{} +} + +func (aReq *AttachmentDetails) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(aReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + aReq.AttachmentID = parseUInt64(chi.URLParam(r, "attachmentID")) + aReq.Kind = chi.URLParam(r, "kind") + + return err +} + +var _ RequestFiller = NewAttachmentDetails() + +// Attachment original request parameters +type AttachmentOriginal struct { + Download bool + AttachmentID uint64 `json:",string"` + Name string + Kind string +} + +func NewAttachmentOriginal() *AttachmentOriginal { + return &AttachmentOriginal{} +} + +func (aReq *AttachmentOriginal) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(aReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := get["download"]; ok { + + aReq.Download = parseBool(val) + } + aReq.AttachmentID = parseUInt64(chi.URLParam(r, "attachmentID")) + aReq.Name = chi.URLParam(r, "name") + aReq.Kind = chi.URLParam(r, "kind") + + return err +} + +var _ RequestFiller = NewAttachmentOriginal() + +// Attachment preview request parameters +type AttachmentPreview struct { + AttachmentID uint64 `json:",string"` + Ext string + Kind string +} + +func NewAttachmentPreview() *AttachmentPreview { + return &AttachmentPreview{} +} + +func (aReq *AttachmentPreview) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(aReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + aReq.AttachmentID = parseUInt64(chi.URLParam(r, "attachmentID")) + aReq.Ext = chi.URLParam(r, "ext") + aReq.Kind = chi.URLParam(r, "kind") + + return err +} + +var _ RequestFiller = NewAttachmentPreview() diff --git a/crm/rest/request/module.go b/crm/rest/request/module.go index 68c113dee..c3d3bf86f 100644 --- a/crm/rest/request/module.go +++ b/crm/rest/request/module.go @@ -175,67 +175,6 @@ func (mReq *ModuleRead) Fill(r *http.Request) (err error) { var _ RequestFiller = NewModuleRead() -// Module attachments request parameters -type ModuleAttachments struct { - Filter string - Page int - PerPage int - Sort string -} - -func NewModuleAttachments() *ModuleAttachments { - return &ModuleAttachments{} -} - -func (mReq *ModuleAttachments) Fill(r *http.Request) (err error) { - if strings.ToLower(r.Header.Get("content-type")) == "application/json" { - err = json.NewDecoder(r.Body).Decode(mReq) - - switch { - case err == io.EOF: - err = nil - case err != nil: - return errors.Wrap(err, "error parsing http request body") - } - } - - if err = r.ParseForm(); err != nil { - return err - } - - get := map[string]string{} - post := map[string]string{} - urlQuery := r.URL.Query() - for name, param := range urlQuery { - get[name] = string(param[0]) - } - postVars := r.Form - for name, param := range postVars { - post[name] = string(param[0]) - } - - if val, ok := get["filter"]; ok { - - mReq.Filter = val - } - if val, ok := get["page"]; ok { - - mReq.Page = parseInt(val) - } - if val, ok := get["perPage"]; ok { - - mReq.PerPage = parseInt(val) - } - if val, ok := get["sort"]; ok { - - mReq.Sort = val - } - - return err -} - -var _ RequestFiller = NewModuleAttachments() - // Module update request parameters type ModuleUpdate struct { ModuleID uint64 `json:",string"` @@ -334,304 +273,3 @@ func (mReq *ModuleDelete) Fill(r *http.Request) (err error) { } var _ RequestFiller = NewModuleDelete() - -// Module record/report request parameters -type ModuleRecordReport struct { - Metrics string - Dimensions string - Filter string - ModuleID uint64 `json:",string"` -} - -func NewModuleRecordReport() *ModuleRecordReport { - return &ModuleRecordReport{} -} - -func (mReq *ModuleRecordReport) Fill(r *http.Request) (err error) { - if strings.ToLower(r.Header.Get("content-type")) == "application/json" { - err = json.NewDecoder(r.Body).Decode(mReq) - - switch { - case err == io.EOF: - err = nil - case err != nil: - return errors.Wrap(err, "error parsing http request body") - } - } - - if err = r.ParseForm(); err != nil { - return err - } - - get := map[string]string{} - post := map[string]string{} - urlQuery := r.URL.Query() - for name, param := range urlQuery { - get[name] = string(param[0]) - } - postVars := r.Form - for name, param := range postVars { - post[name] = string(param[0]) - } - - if val, ok := get["metrics"]; ok { - - mReq.Metrics = val - } - if val, ok := get["dimensions"]; ok { - - mReq.Dimensions = val - } - if val, ok := get["filter"]; ok { - - mReq.Filter = val - } - mReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) - - return err -} - -var _ RequestFiller = NewModuleRecordReport() - -// Module record/list request parameters -type ModuleRecordList struct { - Filter string - Page int - PerPage int - Sort string - ModuleID uint64 `json:",string"` -} - -func NewModuleRecordList() *ModuleRecordList { - return &ModuleRecordList{} -} - -func (mReq *ModuleRecordList) Fill(r *http.Request) (err error) { - if strings.ToLower(r.Header.Get("content-type")) == "application/json" { - err = json.NewDecoder(r.Body).Decode(mReq) - - switch { - case err == io.EOF: - err = nil - case err != nil: - return errors.Wrap(err, "error parsing http request body") - } - } - - if err = r.ParseForm(); err != nil { - return err - } - - get := map[string]string{} - post := map[string]string{} - urlQuery := r.URL.Query() - for name, param := range urlQuery { - get[name] = string(param[0]) - } - postVars := r.Form - for name, param := range postVars { - post[name] = string(param[0]) - } - - if val, ok := get["filter"]; ok { - - mReq.Filter = val - } - if val, ok := get["page"]; ok { - - mReq.Page = parseInt(val) - } - if val, ok := get["perPage"]; ok { - - mReq.PerPage = parseInt(val) - } - if val, ok := get["sort"]; ok { - - mReq.Sort = val - } - mReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) - - return err -} - -var _ RequestFiller = NewModuleRecordList() - -// Module record/create request parameters -type ModuleRecordCreate struct { - ModuleID uint64 `json:",string"` - Values types.RecordValueSet -} - -func NewModuleRecordCreate() *ModuleRecordCreate { - return &ModuleRecordCreate{} -} - -func (mReq *ModuleRecordCreate) Fill(r *http.Request) (err error) { - if strings.ToLower(r.Header.Get("content-type")) == "application/json" { - err = json.NewDecoder(r.Body).Decode(mReq) - - switch { - case err == io.EOF: - err = nil - case err != nil: - return errors.Wrap(err, "error parsing http request body") - } - } - - if err = r.ParseForm(); err != nil { - return err - } - - get := map[string]string{} - post := map[string]string{} - urlQuery := r.URL.Query() - for name, param := range urlQuery { - get[name] = string(param[0]) - } - postVars := r.Form - for name, param := range postVars { - post[name] = string(param[0]) - } - - mReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) - - return err -} - -var _ RequestFiller = NewModuleRecordCreate() - -// Module record/read request parameters -type ModuleRecordRead struct { - ModuleID uint64 `json:",string"` - RecordID uint64 `json:",string"` -} - -func NewModuleRecordRead() *ModuleRecordRead { - return &ModuleRecordRead{} -} - -func (mReq *ModuleRecordRead) Fill(r *http.Request) (err error) { - if strings.ToLower(r.Header.Get("content-type")) == "application/json" { - err = json.NewDecoder(r.Body).Decode(mReq) - - switch { - case err == io.EOF: - err = nil - case err != nil: - return errors.Wrap(err, "error parsing http request body") - } - } - - if err = r.ParseForm(); err != nil { - return err - } - - get := map[string]string{} - post := map[string]string{} - urlQuery := r.URL.Query() - for name, param := range urlQuery { - get[name] = string(param[0]) - } - postVars := r.Form - for name, param := range postVars { - post[name] = string(param[0]) - } - - mReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) - mReq.RecordID = parseUInt64(chi.URLParam(r, "recordID")) - - return err -} - -var _ RequestFiller = NewModuleRecordRead() - -// Module record/update request parameters -type ModuleRecordUpdate struct { - ModuleID uint64 `json:",string"` - RecordID uint64 `json:",string"` - Values types.RecordValueSet -} - -func NewModuleRecordUpdate() *ModuleRecordUpdate { - return &ModuleRecordUpdate{} -} - -func (mReq *ModuleRecordUpdate) Fill(r *http.Request) (err error) { - if strings.ToLower(r.Header.Get("content-type")) == "application/json" { - err = json.NewDecoder(r.Body).Decode(mReq) - - switch { - case err == io.EOF: - err = nil - case err != nil: - return errors.Wrap(err, "error parsing http request body") - } - } - - if err = r.ParseForm(); err != nil { - return err - } - - get := map[string]string{} - post := map[string]string{} - urlQuery := r.URL.Query() - for name, param := range urlQuery { - get[name] = string(param[0]) - } - postVars := r.Form - for name, param := range postVars { - post[name] = string(param[0]) - } - - mReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) - mReq.RecordID = parseUInt64(chi.URLParam(r, "recordID")) - - return err -} - -var _ RequestFiller = NewModuleRecordUpdate() - -// Module record/delete request parameters -type ModuleRecordDelete struct { - ModuleID uint64 `json:",string"` - RecordID uint64 `json:",string"` -} - -func NewModuleRecordDelete() *ModuleRecordDelete { - return &ModuleRecordDelete{} -} - -func (mReq *ModuleRecordDelete) Fill(r *http.Request) (err error) { - if strings.ToLower(r.Header.Get("content-type")) == "application/json" { - err = json.NewDecoder(r.Body).Decode(mReq) - - switch { - case err == io.EOF: - err = nil - case err != nil: - return errors.Wrap(err, "error parsing http request body") - } - } - - if err = r.ParseForm(); err != nil { - return err - } - - get := map[string]string{} - post := map[string]string{} - urlQuery := r.URL.Query() - for name, param := range urlQuery { - get[name] = string(param[0]) - } - postVars := r.Form - for name, param := range postVars { - post[name] = string(param[0]) - } - - mReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) - mReq.RecordID = parseUInt64(chi.URLParam(r, "recordID")) - - return err -} - -var _ RequestFiller = NewModuleRecordDelete() diff --git a/crm/rest/request/page.go b/crm/rest/request/page.go index f3db14244..53dd70918 100644 --- a/crm/rest/request/page.go +++ b/crm/rest/request/page.go @@ -193,67 +193,6 @@ func (pReq *PageRead) Fill(r *http.Request) (err error) { var _ RequestFiller = NewPageRead() -// Page attachments request parameters -type PageAttachments struct { - Filter string - Page int - PerPage int - Sort string -} - -func NewPageAttachments() *PageAttachments { - return &PageAttachments{} -} - -func (pReq *PageAttachments) Fill(r *http.Request) (err error) { - if strings.ToLower(r.Header.Get("content-type")) == "application/json" { - err = json.NewDecoder(r.Body).Decode(pReq) - - switch { - case err == io.EOF: - err = nil - case err != nil: - return errors.Wrap(err, "error parsing http request body") - } - } - - if err = r.ParseForm(); err != nil { - return err - } - - get := map[string]string{} - post := map[string]string{} - urlQuery := r.URL.Query() - for name, param := range urlQuery { - get[name] = string(param[0]) - } - postVars := r.Form - for name, param := range postVars { - post[name] = string(param[0]) - } - - if val, ok := get["filter"]; ok { - - pReq.Filter = val - } - if val, ok := get["page"]; ok { - - pReq.Page = parseInt(val) - } - if val, ok := get["perPage"]; ok { - - pReq.PerPage = parseInt(val) - } - if val, ok := get["sort"]; ok { - - pReq.Sort = val - } - - return err -} - -var _ RequestFiller = NewPageAttachments() - // Page tree request parameters type PageTree struct { } @@ -455,3 +394,50 @@ func (pReq *PageDelete) Fill(r *http.Request) (err error) { } var _ RequestFiller = NewPageDelete() + +// Page upload request parameters +type PageUpload struct { + PageID uint64 `json:",string"` + Upload *multipart.FileHeader +} + +func NewPageUpload() *PageUpload { + return &PageUpload{} +} + +func (pReq *PageUpload) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(pReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseMultipartForm(32 << 20); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + pReq.PageID = parseUInt64(chi.URLParam(r, "pageID")) + if _, pReq.Upload, err = r.FormFile("upload"); err != nil { + return errors.Wrap(err, "error procesing uploaded file") + } + + return err +} + +var _ RequestFiller = NewPageUpload() diff --git a/crm/rest/request/record.go b/crm/rest/request/record.go new file mode 100644 index 000000000..d1717536e --- /dev/null +++ b/crm/rest/request/record.go @@ -0,0 +1,384 @@ +package request + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `record.go`, `record.util.go` or `record_test.go` to + implement your API calls, helper functions and tests. The file `record.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "encoding/json" + "io" + "mime/multipart" + "net/http" + "strings" + + "github.com/go-chi/chi" + "github.com/pkg/errors" + + "github.com/crusttech/crust/crm/types" +) + +var _ = chi.URLParam +var _ = multipart.FileHeader{} + +// Record report request parameters +type RecordReport struct { + Metrics string + Dimensions string + Filter string + ModuleID uint64 `json:",string"` +} + +func NewRecordReport() *RecordReport { + return &RecordReport{} +} + +func (rReq *RecordReport) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(rReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := get["metrics"]; ok { + + rReq.Metrics = val + } + if val, ok := get["dimensions"]; ok { + + rReq.Dimensions = val + } + if val, ok := get["filter"]; ok { + + rReq.Filter = val + } + rReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) + + return err +} + +var _ RequestFiller = NewRecordReport() + +// Record list request parameters +type RecordList struct { + Filter string + Page int + PerPage int + Sort string + ModuleID uint64 `json:",string"` +} + +func NewRecordList() *RecordList { + return &RecordList{} +} + +func (rReq *RecordList) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(rReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := get["filter"]; ok { + + rReq.Filter = val + } + if val, ok := get["page"]; ok { + + rReq.Page = parseInt(val) + } + if val, ok := get["perPage"]; ok { + + rReq.PerPage = parseInt(val) + } + if val, ok := get["sort"]; ok { + + rReq.Sort = val + } + rReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) + + return err +} + +var _ RequestFiller = NewRecordList() + +// Record create request parameters +type RecordCreate struct { + Values types.RecordValueSet + ModuleID uint64 `json:",string"` +} + +func NewRecordCreate() *RecordCreate { + return &RecordCreate{} +} + +func (rReq *RecordCreate) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(rReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + rReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) + + return err +} + +var _ RequestFiller = NewRecordCreate() + +// Record read request parameters +type RecordRead struct { + RecordID uint64 `json:",string"` + ModuleID uint64 `json:",string"` +} + +func NewRecordRead() *RecordRead { + return &RecordRead{} +} + +func (rReq *RecordRead) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(rReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + rReq.RecordID = parseUInt64(chi.URLParam(r, "recordID")) + rReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) + + return err +} + +var _ RequestFiller = NewRecordRead() + +// Record update request parameters +type RecordUpdate struct { + RecordID uint64 `json:",string"` + ModuleID uint64 `json:",string"` + Values types.RecordValueSet +} + +func NewRecordUpdate() *RecordUpdate { + return &RecordUpdate{} +} + +func (rReq *RecordUpdate) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(rReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + rReq.RecordID = parseUInt64(chi.URLParam(r, "recordID")) + rReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) + + return err +} + +var _ RequestFiller = NewRecordUpdate() + +// Record delete request parameters +type RecordDelete struct { + RecordID uint64 `json:",string"` + ModuleID uint64 `json:",string"` +} + +func NewRecordDelete() *RecordDelete { + return &RecordDelete{} +} + +func (rReq *RecordDelete) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(rReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + rReq.RecordID = parseUInt64(chi.URLParam(r, "recordID")) + rReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) + + return err +} + +var _ RequestFiller = NewRecordDelete() + +// Record upload request parameters +type RecordUpload struct { + RecordID uint64 `json:",string"` + FieldName string + ModuleID uint64 `json:",string"` + Upload *multipart.FileHeader +} + +func NewRecordUpload() *RecordUpload { + return &RecordUpload{} +} + +func (rReq *RecordUpload) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(rReq) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseMultipartForm(32 << 20); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + rReq.RecordID = parseUInt64(chi.URLParam(r, "recordID")) + rReq.FieldName = chi.URLParam(r, "fieldName") + rReq.ModuleID = parseUInt64(chi.URLParam(r, "moduleID")) + if _, rReq.Upload, err = r.FormFile("upload"); err != nil { + return errors.Wrap(err, "error procesing uploaded file") + } + + return err +} + +var _ RequestFiller = NewRecordUpload() diff --git a/crm/rest/request/util.go b/crm/rest/request/util.go index 4451032fd..4a1d7096e 100644 --- a/crm/rest/request/util.go +++ b/crm/rest/request/util.go @@ -26,6 +26,15 @@ func parseInt(s string) int { return i } +// parseInt parses a string to int +func parseUint(s string) uint { + if s == "" { + return 0 + } + i, _ := strconv.ParseUint(s, 10, 32) + return uint(i) +} + // parseInt64 parses a string to int64 func parseInt64(s string) int64 { if s == "" { diff --git a/crm/rest/router.go b/crm/rest/router.go index ee216edfa..0aab2893c 100644 --- a/crm/rest/router.go +++ b/crm/rest/router.go @@ -10,10 +10,14 @@ import ( func MountRoutes() func(chi.Router) { var ( module = Module{}.New() + record = Record{}.New() page = Page{}.New() chart = Chart{}.New() trigger = Trigger{}.New() notification = Notification{}.New() + attachment = Attachment{}.New() + // pageAttachment = PageAttachment{}.New() + // recordAttachment = RecordAttachment{}.New() ) // Initialize handlers & controllers. @@ -25,9 +29,13 @@ func MountRoutes() func(chi.Router) { handlers.NewPage(page).MountRoutes(r) handlers.NewModule(module).MountRoutes(r) + handlers.NewRecord(record).MountRoutes(r) handlers.NewChart(chart).MountRoutes(r) handlers.NewTrigger(trigger).MountRoutes(r) handlers.NewNotification(notification).MountRoutes(r) + + // Use alternative handlers that support file serving + handlers.NewAttachmentDownloadable(attachment).MountRoutes(r) }) } } diff --git a/crm/service/attachment.go b/crm/service/attachment.go new file mode 100644 index 000000000..2a1522562 --- /dev/null +++ b/crm/service/attachment.go @@ -0,0 +1,287 @@ +package service + +import ( + "bytes" + "context" + "image" + "image/gif" + "io" + "log" + "net/http" + "path" + "strings" + + "github.com/disintegration/imaging" + "github.com/edwvee/exiffix" + "github.com/pkg/errors" + "github.com/titpetric/factory" + + "github.com/crusttech/crust/crm/repository" + "github.com/crusttech/crust/crm/types" + "github.com/crusttech/crust/internal/auth" + "github.com/crusttech/crust/internal/store" + systemService "github.com/crusttech/crust/system/service" +) + +const ( + attachmentPreviewMaxWidth = 320 + attachmentPreviewMaxHeight = 180 +) + +type ( + attachment struct { + db *factory.DB + ctx context.Context + + store store.Store + usr systemService.UserService + + attachment repository.AttachmentRepository + } + + AttachmentService interface { + With(ctx context.Context) AttachmentService + + FindByID(id uint64) (*types.Attachment, error) + Find(filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) + CreatePageAttachment(name string, size int64, fh io.ReadSeeker, pageID uint64) (*types.Attachment, error) + CreateRecordAttachment(name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (*types.Attachment, error) + OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) + OpenPreview(att *types.Attachment) (io.ReadSeeker, error) + } +) + +func Attachment(store store.Store) AttachmentService { + return (&attachment{ + store: store, + usr: systemService.DefaultUser, + }).With(context.Background()) +} + +func (svc *attachment) With(ctx context.Context) AttachmentService { + db := repository.DB(ctx) + return &attachment{ + db: db, + ctx: ctx, + + store: svc.store, + usr: svc.usr.With(ctx), + + attachment: repository.Attachment(ctx, db), + } +} + +func (svc *attachment) FindByID(id uint64) (*types.Attachment, error) { + // @todo [SECURITY] check if record/page can be accessed + return svc.attachment.FindByID(id) +} + +func (svc *attachment) Find(filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) { + // @todo [SECURITY] enforce filter combination (page / module+record+field) & check access + return svc.attachment.Find(filter) +} + +func (svc *attachment) OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) { + if len(att.Url) == 0 { + return nil, nil + } + + return svc.store.Open(att.Url) +} + +func (svc *attachment) OpenPreview(att *types.Attachment) (io.ReadSeeker, error) { + if len(att.PreviewUrl) == 0 { + return nil, nil + } + + return svc.store.Open(att.PreviewUrl) +} + +func (svc *attachment) CreatePageAttachment(name string, size int64, fh io.ReadSeeker, pageID uint64) (*types.Attachment, error) { + var currentUserID uint64 = auth.GetIdentityFromContext(svc.ctx).Identity() + + // @todo verify if current user can access this page + // @todo verify if current user can upload to this page + + att := &types.Attachment{ + ID: factory.Sonyflake.NextID(), + OwnerID: currentUserID, + Name: strings.TrimSpace(name), + Kind: types.PageAttachment, + } + + return att, svc.create(name, size, fh, att) +} +func (svc *attachment) CreateRecordAttachment(name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (*types.Attachment, error) { + var currentUserID uint64 = auth.GetIdentityFromContext(svc.ctx).Identity() + + // @todo verify if current user can access this record + // @todo verify if current user can upload to this record + + att := &types.Attachment{ + ID: factory.Sonyflake.NextID(), + OwnerID: currentUserID, + Name: strings.TrimSpace(name), + Kind: types.RecordAttachment, + } + + return att, svc.create(name, size, fh, att) +} + +func (svc *attachment) create(name string, size int64, fh io.ReadSeeker, att *types.Attachment) (err error) { + if svc.store == nil { + return errors.New("Can not create attachment: store handler not set") + } + + // Extract extension but make sure path.Ext is not confused by any leading/trailing dots + att.Meta.Original.Extension = strings.Trim(path.Ext(strings.Trim(name, ".")), ".") + + att.Meta.Original.Size = size + if att.Meta.Original.Mimetype, err = svc.extractMimetype(fh); err != nil { + return + } + + log.Printf( + "Processing uploaded file (name: %s, size: %d, mimetype: %s)", + att.Name, + att.Meta.Original.Size, + att.Meta.Original.Mimetype) + + att.Url = svc.store.Original(att.ID, att.Meta.Original.Extension) + if err = svc.store.Save(att.Url, fh); err != nil { + log.Print(err.Error()) + return + } + + // Process image: extract width, height, make preview + log.Printf("Image processed, error: %v", svc.processImage(fh, att)) + + log.Printf("File %s stored as %s", att.Name, att.Url) + + return svc.db.Transaction(func() (err error) { + if att, err = svc.attachment.Create(att); err != nil { + return + } + + return nil + }) +} + +func (svc *attachment) extractMimetype(file io.ReadSeeker) (mimetype string, err error) { + if _, err = file.Seek(0, 0); err != nil { + return + } + + // Make sure we rewind when we're done + defer file.Seek(0, 0) + + // See http.DetectContentType about 512 bytes + var buf = make([]byte, 512) + if _, err = file.Read(buf); err != nil { + return + } + + return http.DetectContentType(buf), nil +} + +func (svc *attachment) processImage(original io.ReadSeeker, att *types.Attachment) (err error) { + if !strings.HasPrefix(att.Meta.Original.Mimetype, "image/") { + // Only supporting previews from images (for now) + return + } + + var ( + preview image.Image + opts []imaging.EncodeOption + format imaging.Format + previewFormat imaging.Format + animated bool + f2m = map[imaging.Format]string{ + imaging.JPEG: "image/jpeg", + imaging.GIF: "image/gif", + } + + f2e = map[imaging.Format]string{ + imaging.JPEG: "jpg", + imaging.GIF: "gif", + } + ) + + if _, err = original.Seek(0, 0); err != nil { + return + } + + if format, err = imaging.FormatFromExtension(att.Meta.Original.Extension); err != nil { + return errors.Wrapf(err, "Could not get format from extension '%s'", att.Meta.Original.Extension) + } + + previewFormat = format + + if imaging.JPEG == format { + // Rotate image if needed + if preview, _, err = exiffix.Decode(original); err != nil { + //return errors.Wrapf(err, "Could not decode EXIF from JPEG") + } + + } + + if imaging.GIF == format { + // Decode all and check loops & delay to determine if GIF is animated or not + if cfg, err := gif.DecodeAll(original); err == nil { + animated = cfg.LoopCount > 0 || len(cfg.Delay) > 1 + + // Use first image for the preview + preview = cfg.Image[0] + } else { + return errors.Wrapf(err, "Could not decode gif config") + } + + } else { + // Use GIF preview for GIFs and JPEG for everything else! + previewFormat = imaging.JPEG + + // Store with a bit lower quality + opts = append(opts, imaging.JPEGQuality(85)) + } + + // In case of JPEG we decode the image and rotate it beforehand + // other cases are handled here + if preview == nil { + if preview, err = imaging.Decode(original); err != nil { + return errors.Wrapf(err, "Could not decode original image") + } + } + + var width, height = preview.Bounds().Max.X, preview.Bounds().Max.Y + att.SetOriginalImageMeta(width, height, animated) + + if width > attachmentPreviewMaxWidth && width > height { + // Landscape does not fit + preview = imaging.Resize(preview, attachmentPreviewMaxWidth, 0, imaging.Lanczos) + } else if height > attachmentPreviewMaxHeight { + // Height does not fit + preview = imaging.Resize(preview, 0, attachmentPreviewMaxHeight, imaging.Lanczos) + } + + // Get dimensions from the preview + width, height = preview.Bounds().Max.X, preview.Bounds().Max.Y + + log.Printf("Generated preview %s (%dx%dpx)", previewFormat, width, height) + + var buf = &bytes.Buffer{} + if err = imaging.Encode(buf, preview, previewFormat); err != nil { + return + } + + meta := att.SetPreviewImageMeta(width, height, false) + meta.Size = int64(buf.Len()) + meta.Mimetype = f2m[previewFormat] + meta.Extension = f2e[previewFormat] + + // Can and how we make a preview of this attachment? + att.PreviewUrl = svc.store.Preview(att.ID, meta.Extension) + + return svc.store.Save(att.PreviewUrl, buf) +} + +var _ AttachmentService = &attachment{} diff --git a/crm/service/service.go b/crm/service/service.go index 6d94d183b..4d4f9afbd 100644 --- a/crm/service/service.go +++ b/crm/service/service.go @@ -1,7 +1,10 @@ package service import ( + "log" "sync" + + "github.com/crusttech/crust/internal/store" ) type ( @@ -19,10 +22,16 @@ var ( DefaultPage PageService DefaultNotification NotificationService DefaultPermissions PermissionsService + DefaultAttachment AttachmentService ) func Init() { o.Do(func() { + fs, err := store.New("var/store") + if err != nil { + log.Fatalf("Failed to initialize store: %v", err) + } + DefaultRecord = Record() DefaultModule = Module() DefaultTrigger = Trigger() @@ -30,5 +39,6 @@ func Init() { DefaultChart = Chart() DefaultNotification = Notification() DefaultPermissions = Permissions() + DefaultAttachment = Attachment(fs) }) } diff --git a/crm/types/attachment.gen.go b/crm/types/attachment.gen.go new file mode 100644 index 000000000..c1850a39a --- /dev/null +++ b/crm/types/attachment.gen.go @@ -0,0 +1,67 @@ +package types + +// Hello! This file is auto-generated. + +type ( + + // AttachmentSet slice of Attachment + // + // This type is auto-generated. + AttachmentSet []*Attachment +) + +// Walk iterates through every slice item and calls w(Attachment) err +// +// This function is auto-generated. +func (set AttachmentSet) Walk(w func(*Attachment) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(Attachment) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set AttachmentSet) Filter(f func(*Attachment) (bool, error)) (out AttachmentSet, err error) { + var ok bool + out = AttachmentSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// FindByID finds items from slice by its ID property +// +// This function is auto-generated. +func (set AttachmentSet) FindByID(ID uint64) *Attachment { + for i := range set { + if set[i].ID == ID { + return set[i] + } + } + + return nil +} + +// IDs returns a slice of uint64s from all items in the set +// +// This function is auto-generated. +func (set AttachmentSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(set)) + + for i := range set { + IDs[i] = set[i].ID + } + + return +} diff --git a/crm/types/attachment.go b/crm/types/attachment.go new file mode 100644 index 000000000..61982617d --- /dev/null +++ b/crm/types/attachment.go @@ -0,0 +1,104 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/pkg/errors" +) + +type ( + Attachment struct { + ID uint64 `db:"id" json:"attachmentID,omitempty"` + OwnerID uint64 `db:"rel_owner" json:"ownerID,omitempty"` + Kind string `db:"kind" json:"-"` + Url string `db:"url" json:"url,omitempty"` + PreviewUrl string `db:"preview_url"json:"previewUrl,omitempty"` + Name string `db:"name" json:"name,omitempty"` + Meta attachmentMeta `db:"meta" json:"meta"` + CreatedAt time.Time `db:"created_at" json:"createdAt,omitempty"` + UpdatedAt *time.Time `db:"updated_at" json:"updatedAt,omitempty"` + DeletedAt *time.Time `db:"deleted_at" json:"deletedAt,omitempty"` + } + + // AttachmentFilter is used for filtering and as a return value from Find + AttachmentFilter struct { + Kind string `json:"kind,omitempty"` + PageID uint64 `json:"pageID,string,omitempty"` + RecordID uint64 `json:"recordID,string,omitempty"` + ModuleID uint64 `json:"moduleID,string,omitempty"` + FieldName string `json:"fieldName,omitempty"` + Filter string `json:"filter"` + Page uint `json:"page"` + PerPage uint `json:"perPage"` + Sort string `json:"sort"` + Count uint `json:"count"` + } + + attachmentImageMeta struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Animated bool `json:"animated"` + } + + attachmentFileMeta struct { + Size int64 `json:"size"` + Extension string `json:"ext"` + Mimetype string `json:"mimetype"` + Image *attachmentImageMeta `json:"image,omitempty"` + } + + attachmentMeta struct { + Original attachmentFileMeta `json:"original"` + Preview *attachmentFileMeta `json:"preview,omitempty"` + } +) + +const ( + PageAttachment string = "page" + RecordAttachment string = "record" +) + +func (a *Attachment) SetOriginalImageMeta(width, height int, animated bool) *attachmentFileMeta { + a.imageMeta(&a.Meta.Original, width, height, animated) + return &a.Meta.Original +} + +func (a *Attachment) SetPreviewImageMeta(width, height int, animated bool) *attachmentFileMeta { + if a.Meta.Preview == nil { + a.Meta.Preview = &attachmentFileMeta{} + } + + a.imageMeta(a.Meta.Preview, width, height, animated) + return a.Meta.Preview +} + +func (a *Attachment) imageMeta(in *attachmentFileMeta, width, height int, animated bool) { + if in.Image == nil { + in.Image = &attachmentImageMeta{} + } + + if width > 0 && height > 0 { + in.Image.Animated = animated + in.Image.Width = width + in.Image.Height = height + } +} + +func (meta *attachmentMeta) Scan(value interface{}) error { + switch value.(type) { + case nil: + *meta = attachmentMeta{} + case []uint8: + if err := json.Unmarshal(value.([]byte), meta); err != nil { + return errors.Wrapf(err, "Can not scan '%v' into attachmentMeta", value) + } + } + + return nil +} + +func (meta attachmentMeta) Value() (driver.Value, error) { + return json.Marshal(meta) +} diff --git a/crm/types/types.go b/crm/types/types.go index 6ab4ab42f..9ac208a2f 100644 --- a/crm/types/types.go +++ b/crm/types/types.go @@ -150,7 +150,7 @@ func (set ModuleFieldSet) FilterByModule(moduleID uint64) (ff ModuleFieldSet) { // IsRef tells us if value of this field be a reference to something (another record, user)? func (f ModuleField) IsRef() bool { - return f.Kind == "Record" || f.Kind == "Owner" + return f.Kind == "Record" || f.Kind == "Owner" || f.Kind == "File" } // UserIDs returns a slice of user IDs from all items in the set diff --git a/docs/crm/README.md b/docs/crm/README.md index 21bd58ab2..88c746e4b 100644 --- a/docs/crm/README.md +++ b/docs/crm/README.md @@ -1,3 +1,76 @@ +# Attachments + +## List, filter all page attachments + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{kind}/` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| pageID | uint64 | GET | Filter attachments by page ID | N/A | NO | +| moduleID | uint64 | GET | Filter attachments by mnodule ID | N/A | NO | +| recordID | uint64 | GET | Filter attachments by record ID | N/A | NO | +| fieldName | string | GET | Filter attachments by field name | N/A | NO | +| page | uint | GET | Page number (0 based) | N/A | NO | +| perPage | uint | GET | Returned items per page (default 50) | N/A | NO | +| kind | string | PATH | Attachment kind | N/A | YES | + +## Attachment details + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{kind}/{attachmentID}` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| attachmentID | uint64 | PATH | Attachment ID | N/A | YES | +| kind | string | PATH | Attachment kind | N/A | YES | + +## Serves attached file + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{kind}/{attachmentID}/original/{name}` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| download | bool | GET | Force file download | N/A | NO | +| attachmentID | uint64 | PATH | Attachment ID | N/A | YES | +| name | string | PATH | File name | N/A | YES | +| kind | string | PATH | Attachment kind | N/A | YES | + +## Serves preview of an attached file + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{kind}/{attachmentID}/preview.{ext}` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| attachmentID | uint64 | PATH | Attachment ID | N/A | YES | +| ext | string | PATH | Preview extension/format | N/A | YES | +| kind | string | PATH | Attachment kind | N/A | YES | + + + + # Charts ## List/read charts from module section @@ -154,102 +227,6 @@ CRM module definitions | --------- | ---- | ------ | ----------- | ------- | --------- | | moduleID | uint64 | PATH | Module ID | N/A | YES | -## Generates report from module records - -#### Method - -| URI | Protocol | Method | Authentication | -| --- | -------- | ------ | -------------- | -| `/module/{moduleID}/report` | HTTP/S | GET | | - -#### Request parameters - -| Parameter | Type | Method | Description | Default | Required? | -| --------- | ---- | ------ | ----------- | ------- | --------- | -| metrics | string | GET | Metrics (eg: 'SUM(money), MAX(calls)') | N/A | NO | -| dimensions | string | GET | Dimensions (eg: 'DATE(foo), status') | N/A | YES | -| filter | string | GET | Filter (eg: 'DATE(foo) > 2010') | N/A | NO | -| moduleID | uint64 | PATH | Module ID | N/A | YES | - -## List/read records from module section - -#### Method - -| URI | Protocol | Method | Authentication | -| --- | -------- | ------ | -------------- | -| `/module/{moduleID}/record` | HTTP/S | GET | | - -#### Request parameters - -| Parameter | Type | Method | Description | Default | Required? | -| --------- | ---- | ------ | ----------- | ------- | --------- | -| filter | string | GET | Filtering condition | N/A | NO | -| page | int | GET | Page number (0 based) | N/A | NO | -| perPage | int | GET | Returned items per page (default 50) | N/A | NO | -| sort | string | GET | Sort field (default id desc) | N/A | NO | -| moduleID | uint64 | PATH | Module ID | N/A | YES | - -## Create record in module section - -#### Method - -| URI | Protocol | Method | Authentication | -| --- | -------- | ------ | -------------- | -| `/module/{moduleID}/record` | HTTP/S | POST | | - -#### Request parameters - -| Parameter | Type | Method | Description | Default | Required? | -| --------- | ---- | ------ | ----------- | ------- | --------- | -| moduleID | uint64 | PATH | Module ID | N/A | YES | -| values | types.RecordValueSet | POST | Record values | N/A | YES | - -## Read records by ID from module section - -#### Method - -| URI | Protocol | Method | Authentication | -| --- | -------- | ------ | -------------- | -| `/module/{moduleID}/record/{recordID}` | HTTP/S | GET | | - -#### Request parameters - -| Parameter | Type | Method | Description | Default | Required? | -| --------- | ---- | ------ | ----------- | ------- | --------- | -| moduleID | uint64 | PATH | Module ID | N/A | YES | -| recordID | uint64 | PATH | Record ID | N/A | YES | - -## Update records in module section - -#### Method - -| URI | Protocol | Method | Authentication | -| --- | -------- | ------ | -------------- | -| `/module/{moduleID}/record/{recordID}` | HTTP/S | POST | | - -#### Request parameters - -| Parameter | Type | Method | Description | Default | Required? | -| --------- | ---- | ------ | ----------- | ------- | --------- | -| moduleID | uint64 | PATH | Module ID | N/A | YES | -| recordID | uint64 | PATH | Record ID | N/A | YES | -| values | types.RecordValueSet | POST | Record values | N/A | YES | - -## Delete record row from module section - -#### Method - -| URI | Protocol | Method | Authentication | -| --- | -------- | ------ | -------------- | -| `/module/{moduleID}/record/{recordID}` | HTTP/S | DELETE | | - -#### Request parameters - -| Parameter | Type | Method | Description | Default | Required? | -| --------- | ---- | ------ | ----------- | ------- | --------- | -| moduleID | uint64 | PATH | Module ID | N/A | YES | -| recordID | uint64 | PATH | Record ID | N/A | YES | - @@ -391,6 +368,141 @@ CRM module pages | --------- | ---- | ------ | ----------- | ------- | --------- | | pageID | uint64 | PATH | Page ID | N/A | YES | +## Uploads attachment to page + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/page/{pageID}/attachment` | HTTP/S | POST | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| pageID | uint64 | PATH | Page ID | N/A | YES | +| upload | *multipart.FileHeader | POST | File to upload | N/A | YES | + + + + +# Records + +CRM records + +## Generates report from module records + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/module/{moduleID}/report` | HTTP/S | GET | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| metrics | string | GET | Metrics (eg: 'SUM(money), MAX(calls)') | N/A | NO | +| dimensions | string | GET | Dimensions (eg: 'DATE(foo), status') | N/A | YES | +| filter | string | GET | Filter (eg: 'DATE(foo) > 2010') | N/A | NO | +| moduleID | uint64 | PATH | Module ID | N/A | YES | + +## List/read records from module section + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/module/{moduleID}/record` | HTTP/S | GET | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| filter | string | GET | Filtering condition | N/A | NO | +| page | int | GET | Page number (0 based) | N/A | NO | +| perPage | int | GET | Returned items per page (default 50) | N/A | NO | +| sort | string | GET | Sort field (default id desc) | N/A | NO | +| moduleID | uint64 | PATH | Module ID | N/A | YES | + +## Create record in module section + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/module/{moduleID}/record` | HTTP/S | POST | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| values | types.RecordValueSet | POST | Record values | N/A | YES | +| moduleID | uint64 | PATH | Module ID | N/A | YES | + +## Read records by ID from module section + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/module/{moduleID}/record/{recordID}` | HTTP/S | GET | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| recordID | uint64 | PATH | Record ID | N/A | YES | +| moduleID | uint64 | PATH | Module ID | N/A | YES | + +## Update records in module section + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/module/{moduleID}/record/{recordID}` | HTTP/S | POST | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| recordID | uint64 | PATH | Record ID | N/A | YES | +| moduleID | uint64 | PATH | Module ID | N/A | YES | +| values | types.RecordValueSet | POST | Record values | N/A | YES | + +## Delete record row from module section + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/module/{moduleID}/record/{recordID}` | HTTP/S | DELETE | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| recordID | uint64 | PATH | Record ID | N/A | YES | +| moduleID | uint64 | PATH | Module ID | N/A | YES | + +## Uploads attachment and validates it against record field requirements + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/module/{moduleID}/record/{recordID}/{fieldName}/attachment` | HTTP/S | POST | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| recordID | uint64 | PATH | Record ID | N/A | YES | +| fieldName | string | PATH | Field name | N/A | YES | +| moduleID | uint64 | PATH | Module ID | N/A | YES | +| upload | *multipart.FileHeader | POST | File to upload | N/A | YES | + diff --git a/messaging/service/service.go b/messaging/service/service.go index d60165fc5..e8d5c12ef 100644 --- a/messaging/service/service.go +++ b/messaging/service/service.go @@ -28,7 +28,7 @@ func Init() { o.Do(func() { fs, err := store.New("var/store") if err != nil { - log.Fatalf("Failed to initialize stor: %v", err) + log.Fatalf("Failed to initialize store: %v", err) } DefaultPermissions = Permissions() diff --git a/system/db/mysql/statik.go b/system/db/mysql/statik.go index 6f732e3b4..24e2793c4 100644 --- a/system/db/mysql/statik.go +++ b/system/db/mysql/statik.go @@ -8,7 +8,7 @@ import ( ) func Data() string { - return "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8-- all known organisations (crust instances) and our relation towards them\nCREATE TABLE organisations (\n id BIGINT UNSIGNED NOT NULL,\n fqn TEXT NOT NULL, -- fully qualified name of the organisation\n name TEXT NOT NULL, -- display name of the organisation\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- organisation soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE settings (\n name VARCHAR(200) NOT NULL COMMENT 'Unique set of setting keys',\n value TEXT COMMENT 'Setting value',\n\n PRIMARY KEY (name)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known users, home and external organisation\n-- changes are stored in audit log\nCREATE TABLE users (\n id BIGINT UNSIGNED NOT NULL,\n email TEXT NOT NULL,\n username TEXT NOT NULL,\n password TEXT NOT NULL,\n name TEXT NOT NULL,\n handle TEXT NOT NULL,\n meta JSON NOT NULL,\n satosa_id CHAR(36) NULL,\n\n rel_organisation BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n suspended_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE UNIQUE INDEX uid_satosa ON users (satosa_id);\n\n-- Keeps all known teams\nCREATE TABLE teams (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the team\n handle TEXT NOT NULL, -- team handle string\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- team soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps team memberships\nCREATE TABLE team_members (\n rel_team BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (rel_team, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xedzU\x8am \x00\x00m \x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00.\x00 \x0020181124181811.rename_and_prefix_tables.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE teams RENAME TO sys_team;\nALTER TABLE organisations RENAME TO sys_organisation;\nALTER TABLE team_members RENAME TO sys_team_member;\nALTER TABLE users RENAME TO sys_user;PK\x07\x08\xf2\xc4\x87\xe8\xb5\x00\x00\x00\xb5\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00 \x0020181125100429.add_user_kind_and_owner.up.sqlUT\x05\x00\x01\x80Cm8# add field to manage user type (bot support)\nALTER TABLE `sys_user` ADD `kind` VARCHAR(8) NOT NULL DEFAULT '' AFTER `handle`;\n\n# add field to manage \"ownership\" (get all bots created by user)\nALTER TABLE `sys_user` ADD `rel_user_id` BIGINT UNSIGNED NOT NULL AFTER `rel_organisation`, ADD INDEX (`rel_user_id`);\nPK\x07\x089\xa0\xdat8\x01\x00\x008\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00 \x0020181125153544.satosa_index_not_unique.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` DROP INDEX `uid_satosa`, ADD INDEX `uid_satosa` (`satosa_id`) USING BTREE;PK\x07\x08\x0d\xf9\xd3ga\x00\x00\x00a\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00!\x00 \x0020181208140000.credentials.up.sqlUT\x05\x00\x01\x80Cm8-- Keeps all known users, home and external organisation\n-- changes are stored in audit log\nCREATE TABLE sys_credentials (\n id BIGINT UNSIGNED NOT NULL,\n rel_owner BIGINT UNSIGNED NOT NULL REFERENCES sys_users(id),\n label TEXT NOT NULL COMMENT 'something we can differentiate credentials by',\n kind VARCHAR(128) NOT NULL COMMENT 'hash, facebook, gplus, github, linkedin ...',\n credentials TEXT NOT NULL COMMENT 'crypted/hashed passwords, secrets, social profile ID',\n meta JSON NOT NULL,\n expires_at DATETIME NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE INDEX idx_owner ON sys_credentials (rel_owner);\nPK\x07\x08f\x1f\x08\xd0\x9a\x03\x00\x00\x9a\x03\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00)\x00 \x0020190103203201.users-password-null.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` MODIFY `password` TEXT NULL;\nPK\x07\x080V\x13\x0f4\x00\x00\x004\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1b\x00 \x0020190116102104.rules.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `sys_rules` (\n `rel_team` BIGINT UNSIGNED NOT NULL,\n `resource` VARCHAR(128) NOT NULL,\n `operation` VARCHAR(128) NOT NULL,\n `value` TINYINT(1) NOT NULL,\n\n PRIMARY KEY (`rel_team`, `resource`, `operation`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\x05\x10[\x91\x05\x01\x00\x00\x05\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00)\x00 \x0020190221001051.rename-team-to-role.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE sys_team RENAME TO sys_role;\nALTER TABLE sys_team_member RENAME TO sys_role_member;\n\nALTER TABLE `sys_role_member` CHANGE COLUMN `rel_team` `rel_role` BIGINT UNSIGNED NOT NULL;\nALTER TABLE `sys_rules` CHANGE COLUMN `rel_team` `rel_role` BIGINT UNSIGNED NOT NULL;\nPK\x07\x08s-\x98\xd0\x13\x01\x00\x00\x13\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020190226160000.everyone_role.up.sqlUT\x05\x00\x01\x80Cm8INSERT INTO `sys_role` (`id`, `name`, `handle`) VALUES (1, 'Everyone', 'everyone');\nINSERT INTO `sys_role` (`id`, `name`, `handle`) VALUES (2, 'Administrators', 'admins');\n\n-- Value: Allow (2), Deny (1), Inherit(0),\nINSERT INTO `sys_rules` (`rel_role`, `resource`, `operation`, `value`) VALUES\n-- Everyone\n (1, 'compose', 'access', 2),\n (1, 'messaging', 'access', 2),\n-- Admins\n (2, 'compose', 'namespace.create', 2),\n (2, 'compose', 'access', 2),\n (2, 'compose', 'grant', 2),\n (2, 'compose:namespace', 'page.create', 2),\n (2, 'compose:namespace', 'read', 2),\n (2, 'compose:namespace', 'update', 2),\n (2, 'compose:namespace', 'delete', 2),\n (2, 'compose:namespace', 'module.create', 2),\n (2, 'compose:namespace', 'chart.create', 2),\n (2, 'compose:namespace', 'trigger.create', 2),\n (2, 'compose:chart', 'read', 2),\n (2, 'compose:chart', 'update', 2),\n (2, 'compose:chart', 'delete', 2),\n (2, 'compose:trigger', 'read', 2),\n (2, 'compose:trigger', 'update', 2),\n (2, 'compose:trigger', 'delete', 2),\n (2, 'compose:page', 'read', 2),\n (2, 'compose:page', 'update', 2),\n (2, 'compose:page', 'delete', 2),\n (2, 'system', 'grant', 2),\n (2, 'system', 'organisation.create', 2),\n (2, 'system', 'role.create', 2),\n (2, 'system:organisation', 'access', 2),\n (2, 'system:role', 'read', 2),\n (2, 'system:role', 'update', 2),\n (2, 'system:role', 'delete', 2),\n (2, 'system:role', 'members.manage', 2),\n (2, 'messaging', 'access', 2),\n (2, 'messaging', 'grant', 2),\n (2, 'messaging', 'channel.public.create', 2),\n (2, 'messaging', 'channel.private.create', 2),\n (2, 'messaging', 'channel.direct.create', 2),\n (2, 'messaging:channel', 'update', 2),\n (2, 'messaging:channel', 'message.attach', 2),\n (2, 'messaging:channel', 'message.update.all', 2),\n (2, 'messaging:channel', 'leave', 2),\n (2, 'messaging:channel', 'webhooks.manage', 2),\n (2, 'messaging:channel', 'message.embed', 2),\n (2, 'messaging:channel', 'members.manage', 2),\n (2, 'messaging:channel', 'attachments.manage', 2),\n (2, 'messaging:channel', 'message.send', 2),\n (2, 'messaging:channel', 'message.reply', 2),\n (2, 'messaging:channel', 'read', 2),\n (2, 'messaging:channel', 'join', 2),\n (2, 'messaging:channel', 'message.update.own', 2),\n (2, 'messaging:channel', 'message.react', 2),\n (2, 'compose:module', 'read', 2),\n (2, 'compose:module', 'update', 2),\n (2, 'compose:module', 'delete', 2),\n (2, 'compose:module', 'record.create', 2),\n (2, 'compose:module', 'record.read', 2),\n (2, 'compose:module', 'record.update', 2),\n (2, 'compose:module', 'record.delete', 2);\nPK\x07\x08\x186!\x1c\x10\n\x00\x00\x10\n\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` TEXT NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x00 \x00new.shUT\x05\x00\x01\x80Cm8#!/bin/bash\ntouch $(date +%Y%m%d%H%M%S).up.sqlPK\x07\x08s\xd4N*.\x00\x00\x00.\x00\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xedzU\x8am \x00\x00m \x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xf2\xc4\x87\xe8\xb5\x00\x00\x00\xb5\x00\x00\x00.\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xbe \x00\x0020181124181811.rename_and_prefix_tables.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(9\xa0\xdat8\x01\x00\x008\x01\x00\x00-\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd8\n\x00\x0020181125100429.add_user_kind_and_owner.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xf9\xd3ga\x00\x00\x00a\x00\x00\x00-\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81t\x0c\x00\x0020181125153544.satosa_index_not_unique.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(f\x1f\x08\xd0\x9a\x03\x00\x00\x9a\x03\x00\x00!\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x819\x0d\x00\x0020181208140000.credentials.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(0V\x13\x0f4\x00\x00\x004\x00\x00\x00)\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81+\x11\x00\x0020190103203201.users-password-null.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x05\x10[\x91\x05\x01\x00\x00\x05\x01\x00\x00\x1b\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xbf\x11\x00\x0020190116102104.rules.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(s-\x98\xd0\x13\x01\x00\x00\x13\x01\x00\x00)\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x16\x13\x00\x0020190221001051.rename-team-to-role.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x186!\x1c\x10\n\x00\x00\x10\n\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x89\x14\x00\x0020190226160000.everyone_role.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf3\x1e\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(s\xd4N*.\x00\x00\x00.\x00\x00\x00\x06\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xed\x81\xb0 \x00\x00new.shUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x0b\x00\x0b\x00\xc4\x03\x00\x00\x1b!\x00\x00\x00\x00" + return "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8-- all known organisations (crust instances) and our relation towards them\nCREATE TABLE organisations (\n id BIGINT UNSIGNED NOT NULL,\n fqn TEXT NOT NULL, -- fully qualified name of the organisation\n name TEXT NOT NULL, -- display name of the organisation\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- organisation soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE settings (\n name VARCHAR(200) NOT NULL COMMENT 'Unique set of setting keys',\n value TEXT COMMENT 'Setting value',\n\n PRIMARY KEY (name)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known users, home and external organisation\n-- changes are stored in audit log\nCREATE TABLE users (\n id BIGINT UNSIGNED NOT NULL,\n email TEXT NOT NULL,\n username TEXT NOT NULL,\n password TEXT NOT NULL,\n name TEXT NOT NULL,\n handle TEXT NOT NULL,\n meta JSON NOT NULL,\n satosa_id CHAR(36) NULL,\n\n rel_organisation BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n suspended_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE UNIQUE INDEX uid_satosa ON users (satosa_id);\n\n-- Keeps all known teams\nCREATE TABLE teams (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the team\n handle TEXT NOT NULL, -- team handle string\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- team soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps team memberships\nCREATE TABLE team_members (\n rel_team BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (rel_team, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xedzU\x8am \x00\x00m \x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00.\x00 \x0020181124181811.rename_and_prefix_tables.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE teams RENAME TO sys_team;\nALTER TABLE organisations RENAME TO sys_organisation;\nALTER TABLE team_members RENAME TO sys_team_member;\nALTER TABLE users RENAME TO sys_user;PK\x07\x08\xf2\xc4\x87\xe8\xb5\x00\x00\x00\xb5\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00 \x0020181125100429.add_user_kind_and_owner.up.sqlUT\x05\x00\x01\x80Cm8# add field to manage user type (bot support)\nALTER TABLE `sys_user` ADD `kind` VARCHAR(8) NOT NULL DEFAULT '' AFTER `handle`;\n\n# add field to manage \"ownership\" (get all bots created by user)\nALTER TABLE `sys_user` ADD `rel_user_id` BIGINT UNSIGNED NOT NULL AFTER `rel_organisation`, ADD INDEX (`rel_user_id`);\nPK\x07\x089\xa0\xdat8\x01\x00\x008\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00-\x00 \x0020181125153544.satosa_index_not_unique.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` DROP INDEX `uid_satosa`, ADD INDEX `uid_satosa` (`satosa_id`) USING BTREE;PK\x07\x08\x0d\xf9\xd3ga\x00\x00\x00a\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00!\x00 \x0020181208140000.credentials.up.sqlUT\x05\x00\x01\x80Cm8-- Keeps all known users, home and external organisation\n-- changes are stored in audit log\nCREATE TABLE sys_credentials (\n id BIGINT UNSIGNED NOT NULL,\n rel_owner BIGINT UNSIGNED NOT NULL REFERENCES sys_users(id),\n label TEXT NOT NULL COMMENT 'something we can differentiate credentials by',\n kind VARCHAR(128) NOT NULL COMMENT 'hash, facebook, gplus, github, linkedin ...',\n credentials TEXT NOT NULL COMMENT 'crypted/hashed passwords, secrets, social profile ID',\n meta JSON NOT NULL,\n expires_at DATETIME NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL, -- user soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE INDEX idx_owner ON sys_credentials (rel_owner);\nPK\x07\x08f\x1f\x08\xd0\x9a\x03\x00\x00\x9a\x03\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00)\x00 \x0020190103203201.users-password-null.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE `sys_user` MODIFY `password` TEXT NULL;\nPK\x07\x080V\x13\x0f4\x00\x00\x004\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1b\x00 \x0020190116102104.rules.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE `sys_rules` (\n `rel_team` BIGINT UNSIGNED NOT NULL,\n `resource` VARCHAR(128) NOT NULL,\n `operation` VARCHAR(128) NOT NULL,\n `value` TINYINT(1) NOT NULL,\n\n PRIMARY KEY (`rel_team`, `resource`, `operation`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\x05\x10[\x91\x05\x01\x00\x00\x05\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00)\x00 \x0020190221001051.rename-team-to-role.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE sys_team RENAME TO sys_role;\nALTER TABLE sys_team_member RENAME TO sys_role_member;\n\nALTER TABLE `sys_role_member` CHANGE COLUMN `rel_team` `rel_role` BIGINT UNSIGNED NOT NULL;\nALTER TABLE `sys_rules` CHANGE COLUMN `rel_team` `rel_role` BIGINT UNSIGNED NOT NULL;\nPK\x07\x08s-\x98\xd0\x13\x01\x00\x00\x13\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00,\x00 \x0020190226160000.system_roles_and_rules.up.sqlUT\x05\x00\x01\x80Cm8INSERT INTO `sys_role` (`id`, `name`, `handle`) VALUES (1, 'Everyone', 'everyone');\nINSERT INTO `sys_role` (`id`, `name`, `handle`) VALUES (2, 'Administrators', 'admins');\n\n-- Value: Allow (2), Deny (1), Inherit(0),\nINSERT INTO `sys_rules` (`rel_role`, `resource`, `operation`, `value`) VALUES\n-- Everyone\n (1, 'compose', 'access', 2),\n (1, 'messaging', 'access', 2),\n-- Admins\n (2, 'compose', 'namespace.create', 2),\n (2, 'compose', 'access', 2),\n (2, 'compose', 'grant', 2),\n (2, 'compose:namespace', 'page.create', 2),\n (2, 'compose:namespace', 'read', 2),\n (2, 'compose:namespace', 'update', 2),\n (2, 'compose:namespace', 'delete', 2),\n (2, 'compose:namespace', 'module.create', 2),\n (2, 'compose:namespace', 'chart.create', 2),\n (2, 'compose:namespace', 'trigger.create', 2),\n (2, 'compose:chart', 'read', 2),\n (2, 'compose:chart', 'update', 2),\n (2, 'compose:chart', 'delete', 2),\n (2, 'compose:trigger', 'read', 2),\n (2, 'compose:trigger', 'update', 2),\n (2, 'compose:trigger', 'delete', 2),\n (2, 'compose:page', 'read', 2),\n (2, 'compose:page', 'update', 2),\n (2, 'compose:page', 'delete', 2),\n (2, 'system', 'grant', 2),\n (2, 'system', 'organisation.create', 2),\n (2, 'system', 'role.create', 2),\n (2, 'system:organisation', 'access', 2),\n (2, 'system:role', 'read', 2),\n (2, 'system:role', 'update', 2),\n (2, 'system:role', 'delete', 2),\n (2, 'system:role', 'members.manage', 2),\n (2, 'messaging', 'access', 2),\n (2, 'messaging', 'grant', 2),\n (2, 'messaging', 'channel.public.create', 2),\n (2, 'messaging', 'channel.private.create', 2),\n (2, 'messaging', 'channel.direct.create', 2),\n (2, 'messaging:channel', 'update', 2),\n (2, 'messaging:channel', 'message.attach', 2),\n (2, 'messaging:channel', 'message.update.all', 2),\n (2, 'messaging:channel', 'leave', 2),\n (2, 'messaging:channel', 'webhooks.manage', 2),\n (2, 'messaging:channel', 'message.embed', 2),\n (2, 'messaging:channel', 'members.manage', 2),\n (2, 'messaging:channel', 'attachments.manage', 2),\n (2, 'messaging:channel', 'message.send', 2),\n (2, 'messaging:channel', 'message.reply', 2),\n (2, 'messaging:channel', 'read', 2),\n (2, 'messaging:channel', 'join', 2),\n (2, 'messaging:channel', 'message.update.own', 2),\n (2, 'messaging:channel', 'message.react', 2),\n (2, 'compose:module', 'read', 2),\n (2, 'compose:module', 'update', 2),\n (2, 'compose:module', 'delete', 2),\n (2, 'compose:module', 'record.create', 2),\n (2, 'compose:module', 'record.read', 2),\n (2, 'compose:module', 'record.update', 2),\n (2, 'compose:module', 'record.delete', 2);\nPK\x07\x08\x186!\x1c\x10\n\x00\x00\x10\n\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` TEXT NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x00 \x00new.shUT\x05\x00\x01\x80Cm8#!/bin/bash\ntouch $(date +%Y%m%d%H%M%S).up.sqlPK\x07\x08s\xd4N*.\x00\x00\x00.\x00\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xedzU\x8am \x00\x00m \x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xf2\xc4\x87\xe8\xb5\x00\x00\x00\xb5\x00\x00\x00.\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xbe \x00\x0020181124181811.rename_and_prefix_tables.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(9\xa0\xdat8\x01\x00\x008\x01\x00\x00-\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd8\n\x00\x0020181125100429.add_user_kind_and_owner.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xf9\xd3ga\x00\x00\x00a\x00\x00\x00-\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81t\x0c\x00\x0020181125153544.satosa_index_not_unique.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(f\x1f\x08\xd0\x9a\x03\x00\x00\x9a\x03\x00\x00!\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x819\x0d\x00\x0020181208140000.credentials.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(0V\x13\x0f4\x00\x00\x004\x00\x00\x00)\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81+\x11\x00\x0020190103203201.users-password-null.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x05\x10[\x91\x05\x01\x00\x00\x05\x01\x00\x00\x1b\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xbf\x11\x00\x0020190116102104.rules.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(s-\x98\xd0\x13\x01\x00\x00\x13\x01\x00\x00)\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x16\x13\x00\x0020190221001051.rename-team-to-role.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x186!\x1c\x10\n\x00\x00\x10\n\x00\x00,\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x89\x14\x00\x0020190226160000.system_roles_and_rules.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xfc\x1e\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(s\xd4N*.\x00\x00\x00.\x00\x00\x00\x06\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xed\x81\xb9 \x00\x00new.shUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x0b\x00\x0b\x00\xcd\x03\x00\x00$!\x00\x00\x00\x00" } func init() {