Merge branch 'compose'

This commit is contained in:
Denis Arh
2019-05-04 17:02:06 +02:00
178 changed files with 6176 additions and 3220 deletions
+14 -8
View File
@@ -15,7 +15,7 @@ steps:
# GOARCH: amd64
# commands:
# - go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/crust-$GOOS-$GOARCH cmd/crust/*.go
# - go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/crm-$GOOS-$GOARCH cmd/crm/*.go
# - go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/compose-$GOOS-$GOARCH cmd/compose/*.go
# - go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/messaging-$GOOS-$GOARCH cmd/messaging/*.go
# - go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/system-$GOOS-$GOARCH cmd/system/*.go
# - go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/system-cli-$GOOS-$GOARCH cmd/system-cli/*.go
@@ -32,12 +32,12 @@ steps:
from_secret: docker_hub_password
- name: api-crm
- name: api-compose
image: plugins/docker
settings:
repo: crusttech/api-crm
repo: crusttech/api-compose
auto_tag: true # generate tag names automatically based on git branch and git tag
dockerfile: Dockerfile.api-crm
dockerfile: Dockerfile.api-compose
username:
from_secret: docker_hub_username
password:
@@ -93,21 +93,27 @@ steps:
CI: circleci
AUTH_OIDC_ENABLED: 0
AUTH_JWT_SECRET: bRxJ37sJ6Qu4
DB_DSN: crust:crust@tcp(crust-db:3306)/crust?collation=utf8mb4_general_ci
SYSTEM_DB_DSN: crust:crust@tcp(crust-db:3306)/crust?collation=utf8mb4_general_ci
COMPOSE_DB_DSN: crust:crust@tcp(crust-db:3306)/crust?collation=utf8mb4_general_ci
MESSAGING_DB_DSN: crust:crust@tcp(crust-db:3306)/crust?collation=utf8mb4_general_ci
commands:
- make mocks
- make codegen
- go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/crust-$GOOS-$GOARCH cmd/crust/*.go
- go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/crm-$GOOS-$GOARCH cmd/crm/*.go
- go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/compose-$GOOS-$GOARCH cmd/compose/*.go
- go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/messaging-$GOOS-$GOARCH cmd/messaging/*.go
- go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/system-$GOOS-$GOARCH cmd/system/*.go
- go build -ldflags "-X github.com/crusttech/crust/internal/version.BuildTime=`date +%FT%T%z` -X github.com/crusttech/crust/internal/version.Version=`git describe --always --tags`" -o build/system-cli-$GOOS-$GOARCH cmd/system-cli/*.go
# check all tests are able to build
- wait-for-it.sh -t 60 --strict crust-db:3306 -- echo "Crust DB1 is up"
- ./build/system-cli-$GOOS-$GOARCH roles reset
- gotest -v --tags="migrations" ./system/db/...
- gotest -v --tags="migrations" ./crm/db/...
- gotest -v --tags="migrations" ./compose/db/...
- gotest -v --tags="migrations" ./messaging/db/...
- go test -run=^$ --tags="unit integration external" ./cmd/... ./internal/... ./crm/... ./messaging/... ./system/...
- gotest -failfast --coverprofile=coverage.txt -v --tags="unit integration" ./internal/... ./system/... ./crm/... ./messaging/...
- go test -run=^$ --tags="unit integration external" ./cmd/... ./internal/... ./compose/... ./messaging/... ./system/...
- gotest -failfast --coverprofile=coverage.txt -v --tags="unit integration" ./internal/... ./system/... ./compose/... ./messaging/...
- name: coverage
image: plugins/codecov
settings:
+5 -5
View File
@@ -4,11 +4,11 @@ MESSAGING_HTTP_ERROR_TRACING=1
MESSAGING_DB_DSN=crust:crust@tcp(localhost:3306)/crust?collation=utf8mb4_general_ci
MESSAGING_DB_PROFILER=stdout
CRM_HTTP_ADDR=:3001
CRM_HTTP_PRETTY_JSON=1
CRM_HTTP_ERROR_TRACING=1
CRM_DB_DSN=crust:crust@tcp(localhost:3306)/crust?collation=utf8mb4_general_ci
CRM_DB_PROFILER=stdout
COMPOSE_HTTP_ADDR=:3001
COMPOSE_HTTP_PRETTY_JSON=1
COMPOSE_HTTP_ERROR_TRACING=1
COMPOSE_DB_DSN=crust:crust@tcp(localhost:3306)/crust?collation=utf8mb4_general_ci
COMPOSE_DB_PROFILER=stdout
SYSTEM_HTTP_ADDR=:3002
SYSTEM_HTTP_PRETTY_JSON=1
+1
View File
@@ -1,6 +1,7 @@
/coverage.txt
/*.iml
/.env*
/.dev*
/.cover*
/public_html
/build
+3 -3
View File
@@ -66,8 +66,8 @@ schema:
- ../../.git
# ######################################################################################################################
- name: crm
path: cmd/crm
- name: cmp
path: cmd/compose
commands:
vet:
status: false
@@ -80,7 +80,7 @@ schema:
watcher:
paths:
- .
- ../../crm
- ../../compose
- ../../internal
extensions:
- go
@@ -8,7 +8,7 @@ COPY . .
RUN mkdir /build; \
go build \
-ldflags "-X github.com/crusttech/crust/internal/version.BuildTime="`date +%FT%T%z`" -X github.com/crusttech/crust/internal/version.Version="`git describe --always --tags` \
-o /build/crm cmd/crm/*.go
-o /build/compose cmd/compose/*.go
## == target image ==
@@ -23,4 +23,4 @@ WORKDIR /crust
EXPOSE 80
ENTRYPOINT /crust/crm
ENTRYPOINT /crust/compose
+9 -9
View File
@@ -1,12 +1,12 @@
.PHONY: help docker docker-push realize dep dep.update test test.messaging test.crm qa critic vet codegen integration
.PHONY: help docker docker-push realize dep dep.update test test.messaging test.compose qa critic vet codegen integration
PKG = "github.com/$(shell cat .project)"
GO = go
GOGET = $(GO) get -u
BASEPKGS = system crm messaging
IMAGES = system crm messaging
BASEPKGS = system compose messaging
IMAGES = system compose messaging
########################################################################################################################
# Tool bins
@@ -25,7 +25,7 @@ help:
@echo
@echo - vet - run go vet on all code
@echo - critic - run go critic on all code
@echo - test.crm - individual package unit tests
@echo - test.compose - individual package unit tests
@echo - test.messaging - individual package unit tests
@echo - test - run all available unit tests
@echo - qa - run vet, critic and test on code
@@ -86,13 +86,13 @@ test.events: $(GOTEST)
perl -pi -e 's/command-line-arguments/.\/messaging\/repository/g' .cover.out
$(GO) tool cover -func=.cover.out | grep --color "^\|[^0-9]0.0%"
test.crm: $(GOTEST)
$(GOTEST) -covermode count -coverprofile .cover.out -v ./crm/service/...
test.compose: $(GOTEST)
$(GOTEST) -covermode count -coverprofile .cover.out -v ./compose/service/...
$(GO) tool cover -func=.cover.out | grep --color "^\|[^0-9]0.0%"
test.crm.db: $(GOTEST)
cd crm/db && $(GO) generate && cd ../..
$(GOTEST) -covermode count -coverprofile .cover.out -v ./crm/db/...
test.compose.db: $(GOTEST)
cd compose/db && $(GO) generate && cd ../..
$(GOTEST) -covermode count -coverprofile .cover.out -v ./compose/db/...
$(GO) tool cover -func=.cover.out | grep --color "^\|[^0-9]0.0%"
test.system: $(GOTEST)
+2 -4
View File
@@ -9,7 +9,7 @@ Crust brings your user ecosystem and essential applications together on one plat
**Crust CRM** is the highly flexible, scalable and open source Salesforce alternative, that enables you to sell faster and interact with leads, clients and team members easier then ever before. Seamless integration with Crust Messaging and Crust Identity and Access Management make it the most complete and flexible self-hosted CRM platform on the market.
**Crust Unify** manages user experience for Crust applications, such as CRM and Messaging, as well as providing an integrated interface for third party or other bespoke applications. 100% responsive and with an intuitive design, Crust Unify increases productivity and ease of access to all IT resources.
**Crust Unify** manages user experience for Crust applications, such as Compose and Messaging, as well as providing an integrated interface for third party or other bespoke applications. 100% responsive and with an intuitive design, Crust Unify increases productivity and ease of access to all IT resources.
## Contributing
@@ -18,9 +18,7 @@ Crust brings your user ecosystem and essential applications together on one plat
Copy `.env.example` to `.env` and make proper modifications for your local environment.
An access to a (local) instance of MySQL must be available.
Configure access to your database with `SAM_DB_DSN` and `CRM_DB_DSN`.
Please check the options available with `./app -h`.
Configure access to your database with `SYSTEM_DB_DSN`, `MESSAGING_DB_DSN` and `COMPOSE_DB_DSN`.
The database will be populated with migrations at the start of each service. You don't need to pre-populate the database, just make sure that your permissions include CREATE and ALTER capabilities.
+350 -26
View File
@@ -24,11 +24,170 @@
}
]
},
{
"title": "Namespaces",
"parameters": {},
"entrypoint": "namespace",
"path": "/namespace",
"authentication": [],
"struct": [
{
"imports": [
"sqlxTypes github.com/jmoiron/sqlx/types",
"time"
]
}
],
"apis": [
{
"name": "list",
"method": "GET",
"title": "List namespaces",
"path": "/",
"parameters": {
"get": [
{
"type": "string",
"name": "query",
"required": false,
"title": "Search query"
},
{
"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": "create",
"method": "POST",
"title": "Create namespace",
"path": "/",
"parameters": {
"post": [
{
"type": "string",
"name": "name",
"required": true,
"title": "Name"
},
{
"type": "string",
"name": "slug",
"required": true,
"title": "Slug (url path part)"
},
{
"type": "bool",
"name": "enabled",
"required": true,
"title": "Enabled"
},
{
"type": "sqlxTypes.JSONText",
"name": "meta",
"required": true,
"title": "Meta data"
}
]
}
},
{
"name": "read",
"method": "GET",
"title": "Read namespace",
"path": "/{namespaceID}",
"parameters": {
"path": [
{
"type": "uint64",
"name": "namespaceID",
"required": true,
"title": "ID"
}
]
}
},
{
"name": "update",
"method": "POST",
"title": "Update namespace",
"path": "/{namespaceID}",
"parameters": {
"path": [
{
"type": "uint64",
"name": "namespaceID",
"required": true,
"title": "ID"
}
],
"post": [
{
"type": "string",
"name": "name",
"required": true,
"title": "Name"
},
{
"type": "string",
"name": "slug",
"required": true,
"title": "Slug (url path part)"
},
{
"type": "bool",
"name": "enabled",
"required": true,
"title": "Enabled"
},
{
"type": "sqlxTypes.JSONText",
"name": "meta",
"required": true,
"title": "Meta data"
},
{
"type": "*time.Time",
"name": "updatedAt",
"required": false,
"title": "Last update (or creation) date"
}
]
}
},
{
"name": "delete",
"method": "DELETE",
"title": "Delete namespace",
"path": "/{namespaceID}",
"parameters": {
"path": [
{
"type": "uint64",
"name": "namespaceID",
"required": true,
"title": "ID"
}
]
}
}
]
},
{
"title": "Pages",
"description": "CRM module pages",
"description": "Compose pages",
"entrypoint": "page",
"path": "/page",
"path": "/namespace/{namespaceID}/page",
"authentication": [],
"struct": [
{
@@ -37,6 +196,16 @@
]
}
],
"parameters": {
"path": [
{
"type": "uint64",
"name": "namespaceID",
"required": true,
"title": "Namespace ID"
}
]
},
"apis": [
{
"name": "list",
@@ -50,6 +219,24 @@
"name": "selfID",
"required": false,
"title": "Parent page ID"
},
{
"type": "string",
"name": "query",
"required": false,
"title": "Search query"
},
{
"name": "page",
"type": "uint",
"required": false,
"title": "Page number (0 based)"
},
{
"name": "perPage",
"type": "uint",
"required": false,
"title": "Returned items per page (default 50)"
}
]
}
@@ -245,15 +432,26 @@
},
{
"title": "Modules",
"description": "CRM module definitions",
"description": "Compose module definitions",
"entrypoint": "module",
"path": "/module",
"path": "/namespace/{namespaceID}/module",
"authentication": [],
"parameters": {
"path": [
{
"type": "uint64",
"name": "namespaceID",
"required": true,
"title": "Namespace ID"
}
]
},
"struct": [
{
"imports": [
"sqlxTypes github.com/jmoiron/sqlx/types",
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/types",
"time"
]
}
],
@@ -270,6 +468,18 @@
"name": "query",
"required": false,
"title": "Search query"
},
{
"name": "page",
"type": "uint",
"required": false,
"title": "Page number (0 based)"
},
{
"name": "perPage",
"type": "uint",
"required": false,
"title": "Returned items per page (default 50)"
}
]
}
@@ -350,6 +560,12 @@
"name": "meta",
"required": true,
"title": "Module meta data"
},
{
"type": "*time.Time",
"name": "updatedAt",
"required": false,
"title": "Last update (or creation) date"
}
]
}
@@ -374,19 +590,25 @@
},
{
"title": "Records",
"description": "CRM records ",
"description": "Compose records",
"entrypoint": "record",
"path": "/module/{moduleID}/record",
"path": "/namespace/{namespaceID}/module/{moduleID}/record",
"authentication": [],
"struct": [
{
"imports": [
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/types"
]
}
],
"parameters": {
"path": [
{
"type": "uint64",
"name": "namespaceID",
"required": true,
"title": "Namespace ID"
},
{
"type": "uint64",
"name": "moduleID",
@@ -439,13 +661,13 @@
},
{
"name": "page",
"type": "int",
"type": "uint",
"required": false,
"title": "Page number (0 based)"
},
{
"name": "perPage",
"type": "int",
"type": "uint",
"required": false,
"title": "Returned items per page (default 50)"
},
@@ -564,27 +786,60 @@
},
{
"title": "Charts",
"path": "/chart",
"path": "/namespace/{namespaceID}/chart",
"entrypoint": "chart",
"authentication": [],
"struct": [
{
"imports": [
"sqlxTypes github.com/jmoiron/sqlx/types"
"sqlxTypes github.com/jmoiron/sqlx/types",
"time"
]
}
],
"parameters": {
"path": [
{
"type": "uint64",
"name": "namespaceID",
"required": true,
"title": "Namespace ID"
}
]
},
"apis": [
{
"name": "list",
"method": "GET",
"title": "List/read charts from module section",
"path": "/"
"title": "List/read charts",
"path": "/",
"parameters": {
"get": [
{
"name": "query",
"required": false,
"title": "Search query to match against charts",
"type": "string"
},
{
"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": "create",
"method": "POST",
"title": "List/read charts from module section",
"title": "List/read charts ",
"path": "/",
"parameters": {
"post": [
@@ -606,7 +861,7 @@
{
"name": "read",
"method": "GET",
"title": "Read charts by ID from module section",
"title": "Read charts by ID",
"path": "/{chartID}",
"parameters": {
"path": [
@@ -622,7 +877,7 @@
{
"name": "update",
"method": "POST",
"title": "Add/update charts in module section",
"title": "Add/update charts",
"path": "/{chartID}",
"parameters": {
"path": [
@@ -645,6 +900,12 @@
"title": "Chart name",
"type": "string",
"required": true
},
{
"type": "*time.Time",
"name": "updatedAt",
"required": false,
"title": "Last update (or creation) date"
}
]
}
@@ -669,10 +930,27 @@
},
{
"title": "Triggers",
"description": "CRM Triggers",
"description": "Compose Triggers",
"entrypoint": "trigger",
"path": "/trigger",
"path": "/namespace/{namespaceID}/trigger",
"authentication": [],
"parameters": {
"path": [
{
"type": "uint64",
"name": "namespaceID",
"required": true,
"title": "Namespace ID"
}
]
},
"struct": [
{
"imports": [
"time"
]
}
],
"apis": [
{
"name": "list",
@@ -686,6 +964,24 @@
"name": "moduleID",
"required": false,
"title": "Filter triggers by module"
},
{
"type": "string",
"name": "query",
"required": false,
"title": "Search query"
},
{
"name": "page",
"type": "uint",
"required": false,
"title": "Page number (0 based)"
},
{
"name": "perPage",
"type": "uint",
"required": false,
"title": "Returned items per page (default 50)"
}
]
}
@@ -726,6 +1022,12 @@
"name": "source",
"required": false,
"title": "Trigger source code"
},
{
"type": "*time.Time",
"name": "updatedAt",
"required": false,
"title": "Last update (or creation) date"
}
]
}
@@ -814,7 +1116,7 @@
},
{
"title": "Notifications",
"description": "CRM Notifications",
"description": "Compose Notifications",
"entrypoint": "notification",
"path": "/notification",
"authentication": [],
@@ -829,7 +1131,7 @@
{
"name": "email/send",
"method": "POST",
"title": "Send email from the CRM",
"title": "Send email from the Compose",
"path": "/email",
"parameters": {
"post": [
@@ -870,7 +1172,7 @@
},
{
"title": "Attachments",
"path": "/attachment/{kind}",
"path": "/namespace/{namespaceID}/attachment/{kind}",
"entrypoint": "attachment",
"authentication": [
"Client ID",
@@ -883,19 +1185,25 @@
"type": "string",
"required": true,
"title": "Attachment kind"
},
{
"type": "uint64",
"name": "namespaceID",
"required": true,
"title": "Namespace ID"
}
],
"get": [
{
"type": "string",
"name": "sign",
"required": true,
"required": false,
"title": "Signature"
},
{
"type": "uint64",
"name": "userID",
"required": true,
"required": false,
"title": "User ID"
}
]
@@ -918,7 +1226,7 @@
"type": "uint64",
"name": "moduleID",
"required": false,
"title": "Filter attachments by mnodule ID"
"title": "Filter attachments by module ID"
},
{
"name": "recordID",
@@ -948,7 +1256,7 @@
}
},
{
"name": "details",
"name": "read",
"path": "/{attachmentID}",
"method": "GET",
"title": "Attachment details",
@@ -963,6 +1271,22 @@
]
}
},
{
"name": "delete",
"path": "/{attachmentID}",
"method": "DELETE",
"title": "Delete attachment",
"parameters": {
"path": [
{
"name": "attachmentID",
"type": "uint64",
"required": true,
"title": "Attachment ID"
}
]
}
},
{
"name": "original",
"path": "/{attachmentID}/original/{name}",
@@ -6,13 +6,13 @@
"get": [
{
"name": "sign",
"required": true,
"required": false,
"title": "Signature",
"type": "string"
},
{
"name": "userID",
"required": true,
"required": false,
"title": "User ID",
"type": "uint64"
}
@@ -23,6 +23,12 @@
"required": true,
"title": "Attachment kind",
"type": "string"
},
{
"name": "namespaceID",
"required": true,
"title": "Namespace ID",
"type": "uint64"
}
]
},
@@ -31,7 +37,7 @@
"Client ID",
"Session ID"
],
"Path": "/attachment/{kind}",
"Path": "/namespace/{namespaceID}/attachment/{kind}",
"APIs": [
{
"Name": "list",
@@ -49,7 +55,7 @@
{
"name": "moduleID",
"required": false,
"title": "Filter attachments by mnodule ID",
"title": "Filter attachments by module ID",
"type": "uint64"
},
{
@@ -80,7 +86,7 @@
}
},
{
"Name": "details",
"Name": "read",
"Method": "GET",
"Title": "Attachment details",
"Path": "/{attachmentID}",
@@ -95,6 +101,22 @@
]
}
},
{
"Name": "delete",
"Method": "DELETE",
"Title": "Delete attachment",
"Path": "/{attachmentID}",
"Parameters": {
"path": [
{
"name": "attachmentID",
"required": true,
"title": "Attachment ID",
"type": "uint64"
}
]
}
},
{
"Name": "original",
"Method": "GET",
@@ -4,26 +4,57 @@
"Struct": [
{
"imports": [
"sqlxTypes github.com/jmoiron/sqlx/types"
"sqlxTypes github.com/jmoiron/sqlx/types",
"time"
]
}
],
"Parameters": null,
"Parameters": {
"path": [
{
"name": "namespaceID",
"required": true,
"title": "Namespace ID",
"type": "uint64"
}
]
},
"Protocol": "",
"Authentication": [],
"Path": "/chart",
"Path": "/namespace/{namespaceID}/chart",
"APIs": [
{
"Name": "list",
"Method": "GET",
"Title": "List/read charts from module section",
"Title": "List/read charts",
"Path": "/",
"Parameters": null
"Parameters": {
"get": [
{
"name": "query",
"required": false,
"title": "Search query to match against charts",
"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": "create",
"Method": "POST",
"Title": "List/read charts from module section",
"Title": "List/read charts ",
"Path": "/",
"Parameters": {
"post": [
@@ -45,7 +76,7 @@
{
"Name": "read",
"Method": "GET",
"Title": "Read charts by ID from module section",
"Title": "Read charts by ID",
"Path": "/{chartID}",
"Parameters": {
"path": [
@@ -61,7 +92,7 @@
{
"Name": "update",
"Method": "POST",
"Title": "Add/update charts in module section",
"Title": "Add/update charts",
"Path": "/{chartID}",
"Parameters": {
"path": [
@@ -84,6 +115,12 @@
"required": true,
"title": "Chart name",
"type": "string"
},
{
"name": "updatedAt",
"required": false,
"title": "Last update (or creation) date",
"type": "*time.Time"
}
]
}
@@ -1,19 +1,29 @@
{
"Title": "Modules",
"Description": "CRM module definitions",
"Description": "Compose module definitions",
"Interface": "Module",
"Struct": [
{
"imports": [
"sqlxTypes github.com/jmoiron/sqlx/types",
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/types",
"time"
]
}
],
"Parameters": null,
"Parameters": {
"path": [
{
"name": "namespaceID",
"required": true,
"title": "Namespace ID",
"type": "uint64"
}
]
},
"Protocol": "",
"Authentication": [],
"Path": "/module",
"Path": "/namespace/{namespaceID}/module",
"APIs": [
{
"Name": "list",
@@ -27,6 +37,18 @@
"required": false,
"title": "Search query",
"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"
}
]
}
@@ -107,6 +129,12 @@
"required": true,
"title": "Module meta data",
"type": "sqlxTypes.JSONText"
},
{
"name": "updatedAt",
"required": false,
"title": "Last update (or creation) date",
"type": "*time.Time"
}
]
}
+160
View File
@@ -0,0 +1,160 @@
{
"Title": "Namespaces",
"Interface": "Namespace",
"Struct": [
{
"imports": [
"sqlxTypes github.com/jmoiron/sqlx/types",
"time"
]
}
],
"Parameters": {},
"Protocol": "",
"Authentication": [],
"Path": "/namespace",
"APIs": [
{
"Name": "list",
"Method": "GET",
"Title": "List namespaces",
"Path": "/",
"Parameters": {
"get": [
{
"name": "query",
"required": false,
"title": "Search query",
"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": "create",
"Method": "POST",
"Title": "Create namespace",
"Path": "/",
"Parameters": {
"post": [
{
"name": "name",
"required": true,
"title": "Name",
"type": "string"
},
{
"name": "slug",
"required": true,
"title": "Slug (url path part)",
"type": "string"
},
{
"name": "enabled",
"required": true,
"title": "Enabled",
"type": "bool"
},
{
"name": "meta",
"required": true,
"title": "Meta data",
"type": "sqlxTypes.JSONText"
}
]
}
},
{
"Name": "read",
"Method": "GET",
"Title": "Read namespace",
"Path": "/{namespaceID}",
"Parameters": {
"path": [
{
"name": "namespaceID",
"required": true,
"title": "ID",
"type": "uint64"
}
]
}
},
{
"Name": "update",
"Method": "POST",
"Title": "Update namespace",
"Path": "/{namespaceID}",
"Parameters": {
"path": [
{
"name": "namespaceID",
"required": true,
"title": "ID",
"type": "uint64"
}
],
"post": [
{
"name": "name",
"required": true,
"title": "Name",
"type": "string"
},
{
"name": "slug",
"required": true,
"title": "Slug (url path part)",
"type": "string"
},
{
"name": "enabled",
"required": true,
"title": "Enabled",
"type": "bool"
},
{
"name": "meta",
"required": true,
"title": "Meta data",
"type": "sqlxTypes.JSONText"
},
{
"name": "updatedAt",
"required": false,
"title": "Last update (or creation) date",
"type": "*time.Time"
}
]
}
},
{
"Name": "delete",
"Method": "DELETE",
"Title": "Delete namespace",
"Path": "/{namespaceID}",
"Parameters": {
"path": [
{
"name": "namespaceID",
"required": true,
"title": "ID",
"type": "uint64"
}
]
}
}
]
}
@@ -1,6 +1,6 @@
{
"Title": "Notifications",
"Description": "CRM Notifications",
"Description": "Compose Notifications",
"Interface": "Notification",
"Struct": [
{
@@ -17,7 +17,7 @@
{
"Name": "email/send",
"Method": "POST",
"Title": "Send email from the CRM",
"Title": "Send email from the Compose",
"Path": "/email",
"Parameters": {
"post": [
@@ -1,6 +1,6 @@
{
"Title": "Pages",
"Description": "CRM module pages",
"Description": "Compose pages",
"Interface": "Page",
"Struct": [
{
@@ -9,10 +9,19 @@
]
}
],
"Parameters": null,
"Parameters": {
"path": [
{
"name": "namespaceID",
"required": true,
"title": "Namespace ID",
"type": "uint64"
}
]
},
"Protocol": "",
"Authentication": [],
"Path": "/page",
"Path": "/namespace/{namespaceID}/page",
"APIs": [
{
"Name": "list",
@@ -26,6 +35,24 @@
"required": false,
"title": "Parent page ID",
"type": "uint64"
},
{
"name": "query",
"required": false,
"title": "Search query",
"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"
}
]
}
@@ -1,16 +1,22 @@
{
"Title": "Records",
"Description": "CRM records ",
"Description": "Compose records",
"Interface": "Record",
"Struct": [
{
"imports": [
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/types"
]
}
],
"Parameters": {
"path": [
{
"name": "namespaceID",
"required": true,
"title": "Namespace ID",
"type": "uint64"
},
{
"name": "moduleID",
"required": true,
@@ -21,7 +27,7 @@
},
"Protocol": "",
"Authentication": [],
"Path": "/module/{moduleID}/record",
"Path": "/namespace/{namespaceID}/module/{moduleID}/record",
"APIs": [
{
"Name": "report",
@@ -68,13 +74,13 @@
"name": "page",
"required": false,
"title": "Page number (0 based)",
"type": "int"
"type": "uint"
},
{
"name": "perPage",
"required": false,
"title": "Returned items per page (default 50)",
"type": "int"
"type": "uint"
},
{
"name": "sort",
@@ -1,12 +1,27 @@
{
"Title": "Triggers",
"Description": "CRM Triggers",
"Description": "Compose Triggers",
"Interface": "Trigger",
"Struct": null,
"Parameters": null,
"Struct": [
{
"imports": [
"time"
]
}
],
"Parameters": {
"path": [
{
"name": "namespaceID",
"required": true,
"title": "Namespace ID",
"type": "uint64"
}
]
},
"Protocol": "",
"Authentication": [],
"Path": "/trigger",
"Path": "/namespace/{namespaceID}/trigger",
"APIs": [
{
"Name": "list",
@@ -20,6 +35,24 @@
"required": false,
"title": "Filter triggers by module",
"type": "uint64"
},
{
"name": "query",
"required": false,
"title": "Search query",
"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"
}
]
}
@@ -60,6 +93,12 @@
"required": false,
"title": "Trigger source code",
"type": "string"
},
{
"name": "updatedAt",
"required": false,
"title": "Last update (or creation) date",
"type": "*time.Time"
}
]
}
+4 -4
View File
@@ -12,7 +12,7 @@ import (
"github.com/crusttech/crust/internal/subscription"
"github.com/crusttech/crust/internal/version"
crm "github.com/crusttech/crust/crm"
compose "github.com/crusttech/crust/compose"
system "github.com/crusttech/crust/system"
)
@@ -24,7 +24,7 @@ func main() {
ctx := context.AsContext(sigctx.New())
crm.Flags("crm")
compose.Flags("compose")
system.Flags("system")
subscription.Flags()
@@ -34,7 +34,7 @@ func main() {
if err := system.Init(ctx); err != nil {
log.Fatalf("Error initializing: %+v", err)
}
if err := crm.Init(ctx); err != nil {
if err := compose.Init(ctx); err != nil {
log.Fatalf("Error initializing: %+v", err)
}
@@ -51,7 +51,7 @@ func main() {
// Disabled for now, system service is the only one that validates subscription
// ctx = subscription.Monitor(ctx)
if err := crm.StartRestAPI(ctx); err != nil {
if err := compose.StartRestAPI(ctx); err != nil {
log.Fatalf("Error starting/running: %+v", err)
}
}
+11 -11
View File
@@ -13,7 +13,7 @@ import (
_ "github.com/joho/godotenv/autoload"
"github.com/namsral/flag"
crm "github.com/crusttech/crust/crm"
compose "github.com/crusttech/crust/compose"
"github.com/crusttech/crust/internal/auth"
messaging "github.com/crusttech/crust/messaging"
system "github.com/crusttech/crust/system"
@@ -55,7 +55,7 @@ func main() {
flags.http = new(config.HTTP).Init()
flags.monitor = new(config.Monitor).Init()
crm.Flags("crm")
compose.Flags("compose")
messaging.Flags("messaging")
system.Flags("system")
@@ -78,8 +78,8 @@ func main() {
if err := system.Init(ctx); err != nil {
log.Fatalf("Error initializing system: %+v", err)
}
if err := crm.Init(ctx); err != nil {
log.Fatalf("Error initializing crm: %+v", err)
if err := compose.Init(ctx); err != nil {
log.Fatalf("Error initializing compose: %+v", err)
}
if err := messaging.Init(ctx); err != nil {
log.Fatalf("Error initializing messaging: %+v", err)
@@ -103,16 +103,16 @@ func main() {
// logging, cors and such
middleware.Mount(ctx, r, flags.http)
// Use JWT secret for hmac signer for now
auth.DefaultSigner = auth.HmacSigner(authJwtFlags.Secret)
auth.DefaultJwtHandler, err = auth.JWT(authJwtFlags.Secret, authJwtFlags.Expiry)
// Use JWT secret for hmac signer for now
auth.DefaultSigner = auth.HmacSigner(authJwtFlags.Secret)
auth.DefaultJwtHandler, err = auth.JWT(authJwtFlags.Secret, authJwtFlags.Expiry)
if err != nil {
log.Fatalf("Error creating JWT Auth: %v", err)
}
r.Route("/api", func(r chi.Router) {
r.Route("/crm", func(r chi.Router) {
crm.MountRoutes(ctx, r)
r.Route("/compose", func(r chi.Router) {
compose.MountRoutes(ctx, r)
})
r.Route("/messaging", func(r chi.Router) {
messaging.MountRoutes(ctx, r)
@@ -125,8 +125,8 @@ func main() {
fileserver := http.FileServer(http.Dir("webapp"))
for _, service := range []string{"admin", "system", "messaging", "crm"} {
r.HandleFunc("/"+service+"*", serveIndex("webapp", "crm/index.html", fileserver))
for _, service := range []string{"admin", "system", "messaging", "compose"} {
r.HandleFunc("/"+service+"*", serveIndex("webapp", "compose/index.html", fileserver))
}
r.HandleFunc("/*", serveIndex("webapp", "index.html", fileserver))
+9 -8
View File
@@ -22,15 +22,16 @@ function types {
CGO_ENABLED=0 go build -o ./build/gen-type-set codegen/v2/type-set.go
fi
./build/gen-type-set --types Attachment --output crm/types/attachment.gen.go
./build/gen-type-set --types Module --output crm/types/module.gen.go
./build/gen-type-set --types Page --output crm/types/page.gen.go
./build/gen-type-set --types Chart --output crm/types/chart.gen.go
./build/gen-type-set --types Trigger --output crm/types/trigger.gen.go
./build/gen-type-set --types Record --output crm/types/record.gen.go
./build/gen-type-set --types Namespace --output compose/types/namespace.gen.go
./build/gen-type-set --types Attachment --output compose/types/attachment.gen.go
./build/gen-type-set --types Module --output compose/types/module.gen.go
./build/gen-type-set --types Page --output compose/types/page.gen.go
./build/gen-type-set --types Chart --output compose/types/chart.gen.go
./build/gen-type-set --types Trigger --output compose/types/trigger.gen.go
./build/gen-type-set --types Record --output compose/types/record.gen.go
./build/gen-type-set --with-primary-key=false --types ModuleField --output crm/types/module_field.gen.go
./build/gen-type-set --with-primary-key=false --types RecordValue --output crm/types/record_value.gen.go
./build/gen-type-set --with-primary-key=false --types ModuleField --output compose/types/module_field.gen.go
./build/gen-type-set --with-primary-key=false --types RecordValue --output compose/types/record_value.gen.go
./build/gen-type-set --types MessageAttachment --output messaging/types/attachment.gen.go
./build/gen-type-set --types Mention --output messaging/types/mention.gen.go
+2
View File
@@ -101,6 +101,8 @@ $parsers = array(
"int" => "parseInt",
"uint" => "parseUint",
"bool" => "parseBool",
"time.Time" => "parseISODateWithErr",
"*time.Time" => "parseISODatePtrWithErr",
"sqlxTypes.JSONText" => "parseJSONTextWithErr",
);
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/pkg/errors"
"github.com/titpetric/factory"
"github.com/crusttech/crust/crm/db/mysql"
"github.com/crusttech/crust/compose/db/mysql"
)
func statements(contents []byte, err error) ([]string, error) {
File diff suppressed because one or more lines are too long
@@ -0,0 +1,30 @@
DROP TABLE IF EXISTS crm_field;
DROP TABLE IF EXISTS crm_fields;
DROP TABLE IF EXISTS crm_content;
DROP TABLE IF EXISTS crm_content_links;
DROP TABLE IF EXISTS crm_content_column;
DROP TABLE IF EXISTS crm_module_content;
ALTER TABLE crm_attachment
RENAME TO compose_attachment;
ALTER TABLE crm_chart
RENAME TO compose_chart;
ALTER TABLE crm_module
RENAME TO compose_module;
ALTER TABLE crm_module_form
RENAME TO compose_module_form;
ALTER TABLE crm_page
RENAME TO compose_page;
ALTER TABLE crm_record
RENAME TO compose_record;
ALTER TABLE crm_record_value
RENAME TO compose_record_value;
ALTER TABLE crm_trigger
RENAME TO compose_trigger;
@@ -0,0 +1,14 @@
CREATE TABLE `compose_namespace` (
`id` BIGINT(20) UNSIGNED NOT NULL,
`name` VARCHAR(64) NOT NULL COMMENT 'Name',
`slug` VARCHAR(64) NOT NULL COMMENT 'URL slug',
`enabled` BOOLEAN NOT NULL COMMENT 'Is namespace enabled?',
`meta` JSON NOT NULL COMMENT 'Meta data',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT NULL,
`deleted_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
@@ -0,0 +1,63 @@
INSERT INTO compose_namespace (id, name, slug, enabled, meta) VALUES (88714882739863655, 'Crust CRM', 'crm', true, '{}');
ALTER TABLE `compose_attachment`
ADD `rel_namespace` BIGINT UNSIGNED NOT NULL AFTER `id`,
ADD INDEX (`rel_namespace`);
ALTER TABLE `compose_chart`
ADD `rel_namespace` BIGINT UNSIGNED NOT NULL AFTER `id`,
ADD INDEX (`rel_namespace`);
ALTER TABLE `compose_module`
ADD `rel_namespace` BIGINT UNSIGNED NOT NULL AFTER `id`,
ADD INDEX (`rel_namespace`);
ALTER TABLE `compose_page`
ADD `rel_namespace` BIGINT UNSIGNED NOT NULL AFTER `id`,
ADD INDEX (`rel_namespace`);
ALTER TABLE `compose_record`
ADD `rel_namespace` BIGINT UNSIGNED NOT NULL AFTER `id`,
ADD INDEX (`rel_namespace`);
ALTER TABLE `compose_trigger`
ADD `rel_namespace` BIGINT UNSIGNED NOT NULL AFTER `id`,
ADD INDEX (`rel_namespace`);
UPDATE `compose_attachment` SET `rel_namespace` = 88714882739863655;
UPDATE `compose_chart` SET `rel_namespace` = 88714882739863655;
UPDATE `compose_module` SET `rel_namespace` = 88714882739863655;
UPDATE `compose_page` SET `rel_namespace` = 88714882739863655;
UPDATE `compose_record` SET `rel_namespace` = 88714882739863655;
UPDATE `compose_trigger` SET `rel_namespace` = 88714882739863655;
ALTER TABLE `compose_attachment`
ADD CONSTRAINT `compose_attachment_namespace`
FOREIGN KEY (`rel_namespace`)
REFERENCES `compose_namespace` (`id`);
ALTER TABLE `compose_chart`
ADD CONSTRAINT `compose_chart_namespace`
FOREIGN KEY (`rel_namespace`)
REFERENCES `compose_namespace` (`id`);
ALTER TABLE `compose_module`
ADD CONSTRAINT `compose_module_namespace`
FOREIGN KEY (`rel_namespace`)
REFERENCES `compose_namespace` (`id`);
ALTER TABLE `compose_page`
ADD CONSTRAINT `compose_page_namespace`
FOREIGN KEY (`rel_namespace`)
REFERENCES `compose_namespace` (`id`);
ALTER TABLE `compose_record`
ADD CONSTRAINT `compose_record_namespace`
FOREIGN KEY (`rel_namespace`)
REFERENCES `compose_namespace` (`id`);
ALTER TABLE `compose_trigger`
ADD CONSTRAINT `compose_trigger_namespace`
FOREIGN KEY (`rel_namespace`)
REFERENCES `compose_namespace` (`id`);
@@ -0,0 +1,6 @@
ALTER TABLE `compose_page`
ADD COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN `updated_at` DATETIME DEFAULT NULL,
ADD COLUMN `deleted_at` DATETIME DEFAULT NULL;
ALTER TABLE `compose_page` CHANGE COLUMN `module_id` `rel_module` BIGINT UNSIGNED NOT NULL DEFAULT 0;
+1 -1
View File
@@ -3,7 +3,7 @@ package service
import (
"github.com/pkg/errors"
"github.com/crusttech/crust/crm/internal/repository"
"github.com/crusttech/crust/compose/internal/repository"
"github.com/crusttech/crust/internal/config"
)
+154
View File
@@ -0,0 +1,154 @@
package repository
import (
"context"
"time"
"github.com/pkg/errors"
"github.com/titpetric/factory"
"gopkg.in/Masterminds/squirrel.v1"
"github.com/crusttech/crust/compose/types"
)
type (
AttachmentRepository interface {
With(ctx context.Context, db *factory.DB) AttachmentRepository
Find(filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error)
FindByID(namespaceID, attachmentID uint64) (*types.Attachment, error)
Create(mod *types.Attachment) (*types.Attachment, error)
DeleteByID(namespaceID, attachmentID uint64) error
}
attachment struct {
*repository
}
)
const (
ErrAttachmentNotFound = repositoryError("AttachmentNotFound")
)
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) table() string {
return "compose_attachment"
}
func (r attachment) columns() []string {
return []string{
"a.id", "a.rel_namespace", "a.rel_owner", "a.kind",
"a.url", "a.preview_url",
"a.name",
"a.meta",
"a.created_at", "a.updated_at", "a.deleted_at",
}
}
func (r attachment) query() squirrel.SelectBuilder {
return squirrel.
Select().
From(r.table() + " AS a").
Where("a.deleted_at IS NULL")
}
func (r attachment) FindByID(namespaceID, attachmentID uint64) (*types.Attachment, error) {
var (
query = r.query().
Columns(r.columns()...).
Where("a.id = ?", attachmentID)
a = &types.Attachment{}
)
if namespaceID > 0 {
query = query.Where("a.rel_namespace = ?", namespaceID)
}
return a, isFound(r.fetchOne(a, query), a.ID > 0, ErrAttachmentNotFound)
}
func (r attachment) Find(filter types.AttachmentFilter) (set types.AttachmentSet, f types.AttachmentFilter, err error) {
f = filter
f.PerPage = normalizePerPage(f.PerPage, 5, 100, 50)
query := r.query().
Where(squirrel.Eq{"a.kind": f.Kind})
if filter.NamespaceID > 0 {
query = query.Where("a.rel_namespace = ?", filter.NamespaceID)
}
switch f.Kind {
case types.PageAttachment:
// @todo implement filtering by page
if f.PageID > 0 {
err = errors.New("filtering by pageID not implemented")
return
}
case types.RecordAttachment:
query = query.
Join("compose_record_value AS v ON (v.ref = a.id)")
if f.ModuleID > 0 {
query = query.
Join("compose_record AS r ON (r.id = v.record_id)").
Where(squirrel.Eq{"r.module_id": f.ModuleID})
}
if f.RecordID > 0 {
query = query.Where(squirrel.Eq{"v.record_id": f.RecordID})
}
if f.FieldName != "" {
query = query.Where(squirrel.Eq{"v.name": f.FieldName})
}
default:
err = errors.New("unsupported kind value")
}
if f.Filter != "" {
err = errors.New("filtering by filter not implemented")
return
}
if f.Count, err = r.count(query); err != nil || f.Count == 0 {
return
}
query = query.
Columns(r.columns()...).
OrderBy("id ASC")
return set, f, r.fetchPaged(&set, query, f.Page, f.PerPage)
}
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(r.table(), mod)
}
func (r attachment) DeleteByID(namespaceID, attachmentID uint64) error {
_, err := r.db().Exec(
"UPDATE "+r.table()+" SET deleted_at = NOW() WHERE rel_namespace = ? AND id = ?",
namespaceID,
attachmentID,
)
return err
}
+124
View File
@@ -0,0 +1,124 @@
package repository
import (
"context"
"time"
"github.com/titpetric/factory"
"gopkg.in/Masterminds/squirrel.v1"
"github.com/crusttech/crust/compose/types"
)
type (
ChartRepository interface {
With(ctx context.Context, db *factory.DB) ChartRepository
FindByID(namespaceID, chartID uint64) (*types.Chart, error)
Find(filter types.ChartFilter) (set types.ChartSet, f types.ChartFilter, err error)
Create(mod *types.Chart) (*types.Chart, error)
Update(mod *types.Chart) (*types.Chart, error)
DeleteByID(namespaceID, chartID uint64) error
}
chart struct {
*repository
}
)
const (
ErrChartNotFound = repositoryError("ChartNotFound")
)
func Chart(ctx context.Context, db *factory.DB) ChartRepository {
return (&chart{}).With(ctx, db)
}
func (r chart) With(ctx context.Context, db *factory.DB) ChartRepository {
return &chart{
repository: r.repository.With(ctx, db),
}
}
func (r chart) table() string {
return "compose_chart"
}
func (r chart) columns() []string {
return []string{
"id", "rel_namespace", "name", "config",
"created_at", "updated_at", "deleted_at",
}
}
func (r chart) query() squirrel.SelectBuilder {
return squirrel.
Select().
From(r.table()).
Where("deleted_at IS NULL")
}
func (r chart) FindByID(namespaceID, chartID uint64) (*types.Chart, error) {
var (
query = r.query().
Columns(r.columns()...).
Where("id = ?", chartID)
c = &types.Chart{}
)
if namespaceID > 0 {
query = query.Where("rel_namespace = ?", namespaceID)
}
return c, isFound(r.fetchOne(c, query), c.ID > 0, ErrChartNotFound)
}
func (r chart) Find(filter types.ChartFilter) (set types.ChartSet, f types.ChartFilter, err error) {
f = filter
f.PerPage = normalizePerPage(f.PerPage, 5, 100, 50)
query := r.query()
if filter.NamespaceID > 0 {
query = query.Where("rel_namespace = ?", filter.NamespaceID)
}
if f.Query != "" {
q := "%" + f.Query + "%"
query = query.Where("name like ?", q)
}
if f.Count, err = r.count(query); err != nil || f.Count == 0 {
return
}
query = query.
Columns(r.columns()...).
OrderBy("id ASC")
return set, f, r.fetchPaged(&set, query, f.Page, f.PerPage)
}
func (r chart) Create(mod *types.Chart) (*types.Chart, error) {
mod.ID = factory.Sonyflake.NextID()
mod.CreatedAt = time.Now()
return mod, r.db().Insert(r.table(), mod)
}
func (r chart) Update(mod *types.Chart) (*types.Chart, error) {
now := time.Now()
mod.UpdatedAt = &now
return mod, r.db().Replace(r.table(), mod)
}
func (r chart) DeleteByID(namespaceID, chartID uint64) error {
_, err := r.db().Exec(
"UPDATE "+r.table()+" SET deleted_at = NOW() WHERE rel_namespace = ? AND id = ?",
namespaceID,
chartID,
)
return err
}
+25
View File
@@ -0,0 +1,25 @@
package repository
import (
"github.com/pkg/errors"
)
type (
repositoryError string
)
const (
ErrNotImplemented = repositoryError("NotImplemented")
)
func (e repositoryError) Error() string {
return e.String()
}
func (e repositoryError) String() string {
return "crust.compose.repository." + string(e)
}
func (e repositoryError) new() error {
return errors.WithStack(e)
}
+166
View File
@@ -0,0 +1,166 @@
package repository
import (
"context"
"time"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"github.com/titpetric/factory"
"gopkg.in/Masterminds/squirrel.v1"
"github.com/crusttech/crust/compose/types"
)
type (
ModuleRepository interface {
With(ctx context.Context, db *factory.DB) ModuleRepository
FindByID(namespaceID, moduleID uint64) (*types.Module, error)
Find(filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error)
FindFields(moduleIDs ...uint64) (ff types.ModuleFieldSet, err error)
Create(mod *types.Module) (*types.Module, error)
Update(mod *types.Module) (*types.Module, error)
DeleteByID(namespaceID, moduleID uint64) error
}
module struct {
*repository
}
)
const (
ErrModuleNotFound = repositoryError("ModuleNotFound")
)
func Module(ctx context.Context, db *factory.DB) ModuleRepository {
return (&module{}).With(ctx, db)
}
func (r module) With(ctx context.Context, db *factory.DB) ModuleRepository {
return &module{
repository: r.repository.With(ctx, db),
}
}
func (r module) table() string {
return "compose_module"
}
func (r module) columns() []string {
return []string{
"id", "rel_namespace", "name", "json",
"created_at", "updated_at", "deleted_at",
}
}
func (r module) query() squirrel.SelectBuilder {
return squirrel.
Select().
From(r.table()).
Where("deleted_at IS NULL")
}
func (r module) FindByID(namespaceID, moduleID uint64) (*types.Module, error) {
var (
query = r.query().
Columns(r.columns()...).
Where("id = ?", moduleID)
c = &types.Module{}
)
if namespaceID > 0 {
query = query.Where("rel_namespace = ?", namespaceID)
}
return c, isFound(r.fetchOne(c, query), c.ID > 0, ErrModuleNotFound)
}
func (r module) Find(filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error) {
f = filter
f.PerPage = normalizePerPage(f.PerPage, 5, 100, 50)
query := r.query()
if filter.NamespaceID > 0 {
query = query.Where("rel_namespace = ?", filter.NamespaceID)
}
if f.Query != "" {
q := "%" + f.Query + "%"
query = query.Where("name like ?", q)
}
if f.Count, err = r.count(query); err != nil || f.Count == 0 {
return
}
query = query.
Columns(r.columns()...).
OrderBy("id ASC")
return set, f, r.fetchPaged(&set, query, f.Page, f.PerPage)
}
func (r module) Create(mod *types.Module) (*types.Module, error) {
mod.ID = factory.Sonyflake.NextID()
mod.CreatedAt = time.Now()
if err := r.updateFields(mod.ID, mod.Fields); err != nil {
return nil, err
}
return mod, r.db().Insert(r.table(), mod)
}
func (r module) Update(mod *types.Module) (*types.Module, error) {
now := time.Now()
mod.UpdatedAt = &now
if err := r.updateFields(mod.ID, mod.Fields); err != nil {
return nil, err
}
return mod, r.db().Replace(r.table(), mod)
}
func (r module) updateFields(moduleID uint64, ff types.ModuleFieldSet) error {
// @todo be more selective when deleting
if _, err := r.db().Exec("DELETE FROM compose_module_form WHERE module_id = ?", moduleID); err != nil {
return errors.Wrap(err, "Error updating module fields")
}
for idx, v := range ff {
v.ModuleID = moduleID
v.Place = idx
if err := r.db().Replace("compose_module_form", v); err != nil {
return errors.Wrap(err, "Error updating module fields")
}
}
return nil
}
func (r module) DeleteByID(namespaceID, moduleID uint64) error {
_, err := r.db().Exec(
"UPDATE "+r.table()+" SET deleted_at = NOW() WHERE rel_namespace = ? AND id = ?",
namespaceID,
moduleID,
)
return err
}
func (r module) FindFields(moduleIDs ...uint64) (ff types.ModuleFieldSet, err error) {
if len(moduleIDs) == 0 {
return
}
if sql, args, err := sqlx.In("SELECT * FROM compose_module_form WHERE module_id IN (?) ORDER BY module_id AND place", moduleIDs); err != nil {
return nil, err
} else {
return ff, r.db().Select(&ff, sql, args...)
}
}
+122
View File
@@ -0,0 +1,122 @@
package repository
import (
"context"
"time"
"github.com/titpetric/factory"
"gopkg.in/Masterminds/squirrel.v1"
"github.com/crusttech/crust/compose/types"
)
type (
NamespaceRepository interface {
With(ctx context.Context, db *factory.DB) NamespaceRepository
FindByID(id uint64) (*types.Namespace, error)
Find(filter types.NamespaceFilter) (types.NamespaceSet, types.NamespaceFilter, error)
Create(mod *types.Namespace) (*types.Namespace, error)
Update(mod *types.Namespace) (*types.Namespace, error)
DeleteByID(id uint64) error
}
namespace struct {
*repository
}
)
const (
ErrNamespaceNotFound = repositoryError("NamespaceNotFound")
)
func Namespace(ctx context.Context, db *factory.DB) NamespaceRepository {
return (&namespace{}).With(ctx, db)
}
func (r namespace) table() string {
return "compose_namespace"
}
func (r namespace) columns() []string {
return []string{
"id",
"name",
"slug",
"enabled",
"meta",
"created_at",
"updated_at",
"deleted_at",
}
}
func (r namespace) query() squirrel.SelectBuilder {
return squirrel.
Select().
From(r.table()).
Where("deleted_at IS NULL")
}
func (r *namespace) With(ctx context.Context, db *factory.DB) NamespaceRepository {
return &namespace{
repository: r.repository.With(ctx, db),
}
}
func (r *namespace) FindByID(namespaceID uint64) (*types.Namespace, error) {
var (
query = r.query().
Columns(r.columns()...).
Where("id = ?", namespaceID)
n = &types.Namespace{}
)
return n, isFound(r.fetchOne(n, query), n.ID > 0, ErrNamespaceNotFound)
}
func (r *namespace) Find(filter types.NamespaceFilter) (set types.NamespaceSet, f types.NamespaceFilter, err error) {
f = filter
f.PerPage = normalizePerPage(f.PerPage, 5, 100, 50)
query := r.query()
if f.Query != "" {
q := "%" + f.Query + "%"
query = query.Where("name like ? OR slug like ?", q, q)
}
if f.Count, err = r.count(query); err != nil || f.Count == 0 {
return
}
if f.Page > 0 {
query = query.Offset(uint64(f.PerPage * f.Page))
}
query = query.
Columns(r.columns()...).
OrderBy("id ASC")
return set, f, r.fetchPaged(&set, query, f.Page, f.PerPage)
}
func (r *namespace) Create(mod *types.Namespace) (*types.Namespace, error) {
mod.ID = factory.Sonyflake.NextID()
mod.CreatedAt = time.Now()
return mod, r.db().Insert(r.table(), mod)
}
func (r *namespace) Update(mod *types.Namespace) (*types.Namespace, error) {
now := time.Now()
mod.UpdatedAt = &now
return mod, r.db().Replace(r.table(), mod)
}
func (r *namespace) DeleteByID(namespaceID uint64) error {
_, err := r.db().Exec("UPDATE "+r.table()+" SET deleted_at = NOW() WHERE id = ?", namespaceID)
return err
}
+188
View File
@@ -0,0 +1,188 @@
package repository
import (
"context"
"time"
"github.com/titpetric/factory"
"gopkg.in/Masterminds/squirrel.v1"
"github.com/crusttech/crust/compose/types"
)
type (
PageRepository interface {
With(ctx context.Context, db *factory.DB) PageRepository
FindByID(namespaceID, pageID uint64) (*types.Page, error)
FindByModuleID(namespaceID, moduleID uint64) (*types.Page, error)
Find(filter types.PageFilter) (set types.PageSet, f types.PageFilter, err error)
Create(mod *types.Page) (*types.Page, error)
Update(mod *types.Page) (*types.Page, error)
DeleteByID(namespaceID, pageID uint64) error
Reorder(namespaceID, selfID uint64, pageIDs []uint64) error
}
page struct {
*repository
}
)
const (
ErrPageNotFound = repositoryError("PageNotFound")
)
func Page(ctx context.Context, db *factory.DB) PageRepository {
return (&page{}).With(ctx, db)
}
func (r page) With(ctx context.Context, db *factory.DB) PageRepository {
return &page{
repository: r.repository.With(ctx, db),
}
}
func (r page) table() string {
return "compose_page"
}
func (r page) columns() []string {
return []string{
"id", "rel_namespace", "self_id", "rel_module", "title",
"blocks", "description", "visible", "weight",
"created_at", "updated_at", "deleted_at",
}
}
func (r page) query() squirrel.SelectBuilder {
return squirrel.
Select().
From(r.table()).
Where("deleted_at IS NULL")
}
func (r page) FindByID(namespaceID, pageID uint64) (*types.Page, error) {
var (
query = r.query().
Columns(r.columns()...).
Where("id = ?", pageID)
c = &types.Page{}
)
if namespaceID > 0 {
query = query.Where("rel_namespace = ?", namespaceID)
}
return c, isFound(r.fetchOne(c, query), c.ID > 0, ErrPageNotFound)
}
func (r page) FindByModuleID(namespaceID, moduleID uint64) (*types.Page, error) {
var (
query = r.query().
Columns(r.columns()...).
Where("rel_module = ?", moduleID)
c = &types.Page{}
)
if namespaceID > 0 {
query = query.Where("rel_namespace = ?", namespaceID)
}
return c, isFound(r.fetchOne(c, query), c.ID > 0, ErrPageNotFound)
}
func (r page) Find(filter types.PageFilter) (set types.PageSet, f types.PageFilter, err error) {
f = filter
f.PerPage = normalizePerPage(f.PerPage, 5, 100, 50)
query := r.query()
if filter.NamespaceID > 0 {
query = query.Where("rel_namespace = ?", filter.NamespaceID)
}
if filter.ParentID > 0 {
query = query.Where("self_id = ?", filter.ParentID)
} else if filter.Root {
query = query.Where("self_id = 0")
}
if f.Query != "" {
q := "%" + f.Query + "%"
query = query.Where("title LIKE ? OR description LIKE ?", q, q)
}
if f.Count, err = r.count(query); err != nil || f.Count == 0 {
return
}
query = query.
Columns(r.columns()...).
OrderBy("weight ASC")
return set, f, r.fetchPaged(&set, query, f.Page, f.PerPage)
}
func (r page) Reorder(namespaceID, parentID uint64, pageIDs []uint64) error {
var (
pageMap = map[uint64]bool{}
filter = types.PageFilter{NamespaceID: namespaceID, ParentID: parentID}
)
if pages, _, err := r.Find(filter); err != nil {
return nil
} else {
for _, page := range pages {
pageMap[page.ID] = true
}
}
weight := 1
db := r.db()
// honor parameter first
for _, pageID := range pageIDs {
if pageMap[pageID] {
pageMap[pageID] = false
if _, err := db.Exec("UPDATE compose_page SET weight = ? WHERE id = ? AND self_id = ?", weight, pageID, parentID); err != nil {
return err
}
weight++
}
}
for pageID, update := range pageMap {
if update {
if _, err := db.Exec("UPDATE compose_page SET weight = ? WHERE id = ? AND self_id = ?", weight, pageID, parentID); err != nil {
return err
}
weight++
}
}
return nil
}
func (r page) Create(mod *types.Page) (*types.Page, error) {
mod.ID = factory.Sonyflake.NextID()
mod.CreatedAt = time.Now()
return mod, r.db().Insert(r.table(), mod)
}
func (r page) Update(mod *types.Page) (*types.Page, error) {
now := time.Now()
mod.UpdatedAt = &now
return mod, r.db().Replace(r.table(), mod)
}
func (r page) DeleteByID(namespaceID, pageID uint64) error {
_, err := r.db().Exec(
"UPDATE "+r.table()+" SET deleted_at = NOW() WHERE rel_namespace = ? AND id = ?",
namespaceID,
pageID,
)
return err
}
+343
View File
@@ -0,0 +1,343 @@
package repository
import (
"context"
"fmt"
"strings"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"github.com/titpetric/factory"
"gopkg.in/Masterminds/squirrel.v1"
"github.com/crusttech/crust/compose/internal/repository/ql"
"github.com/crusttech/crust/compose/types"
)
type (
RecordRepository interface {
With(ctx context.Context, db *factory.DB) RecordRepository
FindByID(namespaceID, recordID uint64) (*types.Record, error)
Report(module *types.Module, metrics, dimensions, filter string) (results interface{}, err error)
Find(module *types.Module, filter types.RecordFilter) (set types.RecordSet, f types.RecordFilter, err error)
Create(record *types.Record) (*types.Record, error)
Update(record *types.Record) (*types.Record, error)
Delete(record *types.Record) error
LoadValues(IDs ...uint64) (rvs types.RecordValueSet, err error)
DeleteValues(record *types.Record) error
UpdateValues(recordID uint64, rvs types.RecordValueSet) (err error)
}
record struct {
*repository
}
)
const (
ErrRecordNotFound = repositoryError("RecordNotFound")
)
func Record(ctx context.Context, db *factory.DB) RecordRepository {
return (&record{}).With(ctx, db)
}
func (r record) With(ctx context.Context, db *factory.DB) RecordRepository {
return &record{
repository: r.repository.With(ctx, db),
}
}
func (r record) table() string {
return "compose_record"
}
func (r record) columns() []string {
return []string{
"r.id",
"r.module_id",
"r.rel_namespace",
"r.owned_by",
"r.created_at",
"r.created_by",
"r.updated_at",
"r.updated_by",
"r.deleted_at",
"r.deleted_by",
}
}
func (r record) query() squirrel.SelectBuilder {
return squirrel.
Select().
From(r.table() + " AS r").
Where("r.deleted_at IS NULL")
}
// @todo: update to accepted DeletedAt column semantics from Messaging
func (r record) FindByID(namespaceID, recordID uint64) (*types.Record, error) {
var (
query = r.query().
Columns(r.columns()...).
Where("id = ?", recordID)
c = &types.Record{}
)
if namespaceID > 0 {
query = query.Where("rel_namespace = ?", namespaceID)
}
return c, isFound(r.fetchOne(c, query), c.ID > 0, ErrRecordNotFound)
}
func (r record) Report(module *types.Module, metrics, dimensions, filter string) (results interface{}, err error) {
crb := NewRecordReportBuilder(module)
var result = make([]map[string]interface{}, 0)
if query, args, err := crb.Build(metrics, dimensions, filter); err != nil {
return nil, errors.Wrap(err, "can not generate report query")
} else if rows, err := r.db().Query(query, args...); err != nil {
return nil, errors.Wrapf(err, "can not execute report query (%s)", query)
} else {
for rows.Next() {
result = append(result, crb.Cast(rows))
}
return result, nil
}
}
func (r record) Find(module *types.Module, filter types.RecordFilter) (set types.RecordSet, f types.RecordFilter, err error) {
var query squirrel.SelectBuilder
f = filter
f.PerPage = normalizePerPage(f.PerPage, 5, 100, 50)
query, err = r.buildQuery(module, filter)
if err != nil {
return
}
if f.Count, err = r.count(query); err != nil || f.Count == 0 {
return
}
// Assemble SQL for fetching record (where + sorting + paging)...
query = query.
Columns(r.columns()...)
return set, f, r.fetchPaged(&set, query, f.Page, f.PerPage)
}
func (r record) buildQuery(module *types.Module, f types.RecordFilter) (query squirrel.SelectBuilder, err error) {
// Create query for fetching and counting records.
query = r.query().
Where("r.module_id = ?", module.ID).
Where("r.rel_namespace = ?", module.NamespaceID)
var joinedFields = []string{}
var alreadyJoined = func(f string) bool {
for _, a := range joinedFields {
if a == f {
return true
}
}
joinedFields = append(joinedFields, f)
return false
}
// Parse filters.
if f.Filter != "" {
var (
// Filter parser
fp = ql.NewParser()
// Filter node
fn ql.ASTNode
)
// Make a nice wrapper that will translate module fields to subqueries
fp.OnIdent = func(i ql.Ident) (ql.Ident, error) {
var is bool
if i.Value, is = isRealRecordCol(i.Value); is {
return i, nil
}
if !module.Fields.HasName(i.Value) {
return i, errors.Errorf("unknown field %q", i.Value)
}
if !alreadyJoined(i.Value) {
query = query.LeftJoin(fmt.Sprintf(
"compose_record_value AS rv_%s ON (rv_%s.record_id = r.id AND rv_%s.name = ? AND rv_%s.deleted_at IS NULL)",
i.Value, i.Value, i.Value, i.Value,
), i.Value)
}
// @todo switch value for ref when doing Record/Owner lookup
i.Value = fmt.Sprintf("rv_%s.value", i.Value)
return i, nil
}
if fn, err = fp.ParseExpression(f.Filter); err != nil {
return
} else if filterSql, filterArgs, err := fn.ToSql(); err != nil {
return query, err
} else {
query = query.Where("("+filterSql+")", filterArgs...)
}
}
if f.Sort != "" {
var (
// Sort parser
sp = ql.NewParser()
// Sort columns
sc ql.Columns
)
sp.OnIdent = func(i ql.Ident) (ql.Ident, error) {
var is bool
if i.Value, is = isRealRecordCol(i.Value); is {
i.Value += " "
return i, nil
}
if !module.Fields.HasName(i.Value) {
return i, errors.Errorf("unknown field %q", i.Value)
}
if !alreadyJoined(i.Value) {
query = query.LeftJoin(fmt.Sprintf(
"compose_record_value AS rv_%s ON (rv_%s.record_id = r.id AND rv_%s.name = ? AND rv_%s.deleted_at IS NULL)",
i.Value, i.Value, i.Value, i.Value,
), i.Value)
}
// @todo switch value for ref when doing Record/Owner lookup
i.Value = fmt.Sprintf("rv_%s.value ", i.Value)
return i, nil
}
if sc, err = sp.ParseColumns(f.Sort); err != nil {
return
}
query = query.OrderBy(sc.Strings()...)
}
return
}
func (r record) Create(record *types.Record) (*types.Record, error) {
record.ID = factory.Sonyflake.NextID()
if err := r.db().Replace("compose_record", record); err != nil {
return nil, errors.Wrap(err, "could not update record")
}
return record, nil
}
func (r record) Update(record *types.Record) (*types.Record, error) {
if err := r.db().Replace("compose_record", record); err != nil {
return nil, errors.Wrap(err, "could not update record")
}
return record, nil
}
func (r record) Delete(record *types.Record) error {
_, err := r.db().Exec(
"UPDATE compose_record SET deleted_at = ?, deleted_by = ? WHERE rel_namespace = ? AND id = ?",
record.DeletedAt,
record.DeletedBy,
record.NamespaceID,
record.ID,
)
return err
}
func (r record) DeleteValues(record *types.Record) error {
_, err := r.db().Exec(
"UPDATE compose_record_value SET deleted_at = ? WHERE record_id = ?",
record.DeletedAt,
record.ID)
return err
}
func (r record) UpdateValues(recordID uint64, rvs types.RecordValueSet) (err error) {
// Remove all records and prepare to be updated
// @todo be more selective and delete only removed values
if _, err = r.db().Exec("DELETE FROM compose_record_value WHERE record_id = ?", recordID); err != nil {
return errors.Wrap(err, "could not remove record values")
}
err = rvs.Walk(func(value *types.RecordValue) error {
value.RecordID = recordID
return r.db().Replace("compose_record_value", value)
})
return errors.Wrap(err, "could not replace record values")
}
func (r record) LoadValues(IDs ...uint64) (rvs types.RecordValueSet, err error) {
if len(IDs) == 0 {
return
}
var sql = "SELECT record_id, name, value, ref, place, deleted_at " +
" FROM compose_record_value " +
" WHERE record_id IN (?) " +
" AND deleted_at IS NULL " +
" ORDER BY record_id, place"
if sql, args, err := sqlx.In(sql, IDs); err != nil {
return nil, err
} else {
return rvs, r.db().Select(&rvs, sql, args...)
}
}
// Checks if field name is "real column", reformats it and returns
func isRealRecordCol(name string) (string, bool) {
switch name {
case
"id",
"module_id",
"owned_by",
"created_by",
"created_at",
"updated_by",
"updated_at",
"deleted_by",
"deleted_at":
return name, true
case
"moduleID",
"ownedBy",
"createdBy",
"createdAt",
"updatedBy",
"updatedAt",
"deletedBy",
"deletedAt":
return name[0:len(name)-2] + "_" + strings.ToLower(name[len(name)-2:]), true
}
return name, false
}
@@ -9,8 +9,8 @@ import (
"github.com/pkg/errors"
"gopkg.in/Masterminds/squirrel.v1"
"github.com/crusttech/crust/crm/internal/repository/ql"
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/internal/repository/ql"
"github.com/crusttech/crust/compose/types"
)
type (
@@ -53,7 +53,7 @@ func NewRecordReportBuilder(module *types.Module) *recordReportBuilder {
var report = squirrel.
Select().
Column(squirrel.Alias(squirrel.Expr("COUNT(*)"), "count")).
From("crm_record").
From("compose_record").
Where("module_id = ?", module.ID)
return &recordReportBuilder{
@@ -88,7 +88,7 @@ func (b *recordReportBuilder) Build(metrics, dimensions, filters string) (sql st
if !alreadyJoined(i.Value) {
b.report = b.report.LeftJoin(fmt.Sprintf(
"crm_record_value AS rv_%s ON (rv_%s.record_id = crm_record.id AND rv_%s.name = ? AND rv_%s.deleted_at IS NULL)",
"compose_record_value AS rv_%s ON (rv_%s.record_id = compose_record.id AND rv_%s.name = ? AND rv_%s.deleted_at IS NULL)",
i.Value, i.Value, i.Value, i.Value,
), i.Value)
}
@@ -5,7 +5,7 @@ package repository
import (
"testing"
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/test"
)
@@ -22,9 +22,9 @@ func TestRecordReportBuilder2(t *testing.T) {
expected := "SELECT (COUNT(*)) AS count, (CAST(max(rv_single1.value) AS DECIMAL(14,2))) AS metric_0, " +
"(QUARTER(rv_ref1.value)) AS dimension_0 " +
"FROM crm_record " +
"LEFT JOIN crm_record_value AS rv_single1 ON (rv_single1.record_id = crm_record.id AND rv_single1.name = ? AND rv_single1.deleted_at IS NULL) " +
"LEFT JOIN crm_record_value AS rv_ref1 ON (rv_ref1.record_id = crm_record.id AND rv_ref1.name = ? AND rv_ref1.deleted_at IS NULL) " +
"FROM compose_record " +
"LEFT JOIN compose_record_value AS rv_single1 ON (rv_single1.record_id = compose_record.id AND rv_single1.name = ? AND rv_single1.deleted_at IS NULL) " +
"LEFT JOIN compose_record_value AS rv_ref1 ON (rv_ref1.record_id = compose_record.id AND rv_ref1.name = ? AND rv_ref1.deleted_at IS NULL) " +
"WHERE module_id = ? AND rv_ref1.value = 2 " +
"GROUP BY dimension_0 " +
"ORDER BY dimension_0"
@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/test"
)
+124
View File
@@ -0,0 +1,124 @@
package repository
import (
"context"
"github.com/pkg/errors"
"github.com/titpetric/factory"
"gopkg.in/Masterminds/squirrel.v1"
"github.com/crusttech/crust/internal/auth"
)
type (
repository struct {
ctx context.Context
dbh *factory.DB
}
)
// DB produces a contextual DB handle
func DB(ctx context.Context) *factory.DB {
return factory.Database.MustGet("compose").With(ctx)
}
// Identity returns the User ID from context
func Identity(ctx context.Context) uint64 {
return auth.GetIdentityFromContext(ctx).Identity()
}
// With updates repository and database contexts
func (r *repository) With(ctx context.Context, db *factory.DB) *repository {
return &repository{
ctx: ctx,
dbh: db,
}
}
// Context returns current active repository context
func (r *repository) Context() context.Context {
return r.ctx
}
// db returns context-aware db handle
func (r *repository) db() *factory.DB {
if r.dbh != nil {
return r.dbh
}
return DB(r.ctx)
}
// Fetches single row from table
func (r repository) fetchOne(one interface{}, q squirrel.SelectBuilder) (err error) {
var (
sql string
args []interface{}
)
if sql, args, err = q.ToSql(); err != nil {
return
}
if err = r.db().Get(one, sql, args...); err != nil {
return
}
return
}
// Counts all rows that match conditions from given query builder
func (r repository) count(q squirrel.SelectBuilder) (uint, error) {
var (
count uint
cq = q.Column("COUNT(*)")
)
if sqlSelect, argsSelect, err := cq.ToSql(); err != nil {
return 0, err
} else {
if err := r.db().Get(&count, sqlSelect, argsSelect...); err != nil {
return 0, err
}
}
return count, nil
}
// Fetches paged rows
func (r repository) fetchPaged(set interface{}, q squirrel.SelectBuilder, page, perPage uint) error {
q = q.
Limit(uint64(perPage)).
Offset(uint64(page * perPage))
if sqlSelect, argsSelect, err := q.ToSql(); err != nil {
return err
} else {
return r.db().Select(set, sqlSelect, argsSelect...)
}
}
func normalizePerPage(val, min, max, def uint) uint {
if val == 0 {
return def
}
if val > max {
return max
}
if val < min {
return min
}
return val
}
func isFound(err error, valid bool, nerr error) error {
if err != nil {
return errors.WithStack(err)
} else if !valid {
return errors.WithStack(nerr)
}
return nil
}
+125
View File
@@ -0,0 +1,125 @@
package repository
import (
"context"
"time"
"github.com/titpetric/factory"
"gopkg.in/Masterminds/squirrel.v1"
"github.com/crusttech/crust/compose/types"
)
type (
TriggerRepository interface {
With(ctx context.Context, db *factory.DB) TriggerRepository
FindByID(namespaceID, triggerID uint64) (*types.Trigger, error)
Find(filter types.TriggerFilter) (set types.TriggerSet, f types.TriggerFilter, err error)
Create(mod *types.Trigger) (*types.Trigger, error)
Update(mod *types.Trigger) (*types.Trigger, error)
DeleteByID(namespaceID, triggerID uint64) error
}
trigger struct {
*repository
}
)
const (
ErrTriggerNotFound = repositoryError("TriggerNotFound")
)
func Trigger(ctx context.Context, db *factory.DB) TriggerRepository {
return (&trigger{}).With(ctx, db)
}
func (r trigger) With(ctx context.Context, db *factory.DB) TriggerRepository {
return &trigger{
repository: r.repository.With(ctx, db),
}
}
func (r trigger) table() string {
return "compose_trigger"
}
func (r trigger) columns() []string {
return []string{
"id", "rel_namespace", "name",
"actions", "enabled", "source", "rel_module",
"created_at", "updated_at", "deleted_at",
}
}
func (r trigger) query() squirrel.SelectBuilder {
return squirrel.
Select().
From(r.table()).
Where("deleted_at IS NULL")
}
func (r trigger) FindByID(namespaceID, triggerID uint64) (*types.Trigger, error) {
var (
query = r.query().
Columns(r.columns()...).
Where("id = ?", triggerID)
c = &types.Trigger{}
)
if namespaceID > 0 {
query = query.Where("rel_namespace = ?", namespaceID)
}
return c, isFound(r.fetchOne(c, query), c.ID > 0, ErrTriggerNotFound)
}
func (r trigger) Find(filter types.TriggerFilter) (set types.TriggerSet, f types.TriggerFilter, err error) {
f = filter
f.PerPage = normalizePerPage(f.PerPage, 5, 100, 50)
query := r.query()
if filter.NamespaceID > 0 {
query = query.Where("rel_namespace = ?", filter.NamespaceID)
}
if f.Query != "" {
q := "%" + f.Query + "%"
query = query.Where("name like ?", q)
}
if f.Count, err = r.count(query); err != nil || f.Count == 0 {
return
}
query = query.
Columns(r.columns()...).
OrderBy("id ASC")
return set, f, r.fetchPaged(&set, query, f.Page, f.PerPage)
}
func (r trigger) Create(mod *types.Trigger) (*types.Trigger, error) {
mod.ID = factory.Sonyflake.NextID()
mod.CreatedAt = time.Now()
return mod, r.db().Insert(r.table(), mod)
}
func (r trigger) Update(mod *types.Trigger) (*types.Trigger, error) {
now := time.Now()
mod.UpdatedAt = &now
return mod, r.db().Replace(r.table(), mod)
}
func (r trigger) DeleteByID(namespaceID, triggerID uint64) error {
_, err := r.db().Exec(
"UPDATE "+r.table()+" SET deleted_at = NOW() WHERE rel_namespace = ? AND id = ?",
namespaceID,
triggerID,
)
return err
}
@@ -16,8 +16,8 @@ import (
"github.com/pkg/errors"
"github.com/titpetric/factory"
"github.com/crusttech/crust/crm/internal/repository"
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/internal/repository"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/store"
@@ -48,12 +48,13 @@ type (
AttachmentService interface {
With(ctx context.Context) AttachmentService
FindByID(id uint64) (*types.Attachment, error)
FindByID(namespaceID, attachmentID 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)
CreatePageAttachment(namespaceID uint64, name string, size int64, fh io.ReadSeeker, pageID uint64) (*types.Attachment, error)
CreateRecordAttachment(namespaceID uint64, 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)
DeleteByID(namespaceID, attachmentID uint64) error
}
)
@@ -68,7 +69,7 @@ func Attachment(store store.Store) AttachmentService {
}).With(context.Background())
}
func (svc *attachment) With(ctx context.Context) AttachmentService {
func (svc attachment) With(ctx context.Context) AttachmentService {
db := repository.DB(ctx)
return &attachment{
db: db,
@@ -85,25 +86,41 @@ func (svc *attachment) With(ctx context.Context) AttachmentService {
}
}
func (svc *attachment) FindByID(id uint64) (*types.Attachment, error) {
return svc.attachment.FindByID(id)
func (svc attachment) FindByID(namespaceID, attachmentID uint64) (*types.Attachment, error) {
if namespaceID == 0 {
return nil, ErrNamespaceRequired
}
return svc.attachment.FindByID(namespaceID, attachmentID)
}
func (svc *attachment) Find(filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) {
func (svc attachment) DeleteByID(namespaceID, attachmentID uint64) error {
if namespaceID == 0 {
return ErrNamespaceRequired
}
return svc.attachment.DeleteByID(namespaceID, attachmentID)
}
func (svc attachment) Find(filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) {
if filter.NamespaceID == 0 {
return nil, filter, ErrNamespaceRequired
}
if filter.PageID > 0 {
if _, err := svc.pageSvc.FindByID(filter.PageID); err != nil {
if _, err := svc.pageSvc.FindByID(filter.NamespaceID, filter.PageID); err != nil {
return nil, filter, err
}
}
if filter.ModuleID > 0 {
if _, err := svc.moduleSvc.FindByID(filter.ModuleID); err != nil {
if _, err := svc.moduleSvc.FindByID(filter.NamespaceID, filter.ModuleID); err != nil {
return nil, filter, err
}
}
if filter.RecordID > 0 {
if _, err := svc.recordSvc.FindByID(filter.RecordID); err != nil {
if _, err := svc.recordSvc.FindByID(filter.NamespaceID, filter.RecordID); err != nil {
return nil, filter, err
}
}
@@ -111,7 +128,7 @@ func (svc *attachment) Find(filter types.AttachmentFilter) (types.AttachmentSet,
return svc.attachment.Find(filter)
}
func (svc *attachment) OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) {
func (svc attachment) OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) {
if len(att.Url) == 0 {
return nil, nil
}
@@ -119,7 +136,7 @@ func (svc *attachment) OpenOriginal(att *types.Attachment) (io.ReadSeeker, error
return svc.store.Open(att.Url)
}
func (svc *attachment) OpenPreview(att *types.Attachment) (io.ReadSeeker, error) {
func (svc attachment) OpenPreview(att *types.Attachment) (io.ReadSeeker, error) {
if len(att.PreviewUrl) == 0 {
return nil, nil
}
@@ -127,46 +144,56 @@ func (svc *attachment) OpenPreview(att *types.Attachment) (io.ReadSeeker, error)
return svc.store.Open(att.PreviewUrl)
}
func (svc *attachment) CreatePageAttachment(name string, size int64, fh io.ReadSeeker, pageID uint64) (*types.Attachment, error) {
func (svc attachment) CreatePageAttachment(namespaceID uint64, name string, size int64, fh io.ReadSeeker, pageID uint64) (*types.Attachment, error) {
if namespaceID == 0 {
return nil, ErrNamespaceRequired
}
var currentUserID uint64 = auth.GetIdentityFromContext(svc.ctx).Identity()
if p, err := svc.pageSvc.FindByID(pageID); err != nil {
if p, err := svc.pageSvc.FindByID(namespaceID, pageID); err != nil {
return nil, err
} else if !svc.prmSvc.CanUpdatePage(p) {
return nil, errors.New("not allowed to add attachments to this page")
}
att := &types.Attachment{
ID: factory.Sonyflake.NextID(),
OwnerID: currentUserID,
Name: strings.TrimSpace(name),
Kind: types.PageAttachment,
ID: factory.Sonyflake.NextID(),
NamespaceID: namespaceID,
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) {
func (svc attachment) CreateRecordAttachment(namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (*types.Attachment, error) {
if namespaceID == 0 {
return nil, ErrNamespaceRequired
}
var currentUserID uint64 = auth.GetIdentityFromContext(svc.ctx).Identity()
if _, err := svc.moduleSvc.FindByID(moduleID); err != nil {
if _, err := svc.moduleSvc.FindByID(namespaceID, moduleID); err != nil {
return nil, err
} else if r, err := svc.recordSvc.FindByID(recordID); err != nil {
} else if r, err := svc.recordSvc.FindByID(namespaceID, recordID); err != nil {
return nil, err
} else if !svc.prmSvc.CanUpdateRecord(r) {
return nil, errors.New("not allowed to add attachments to this record")
}
att := &types.Attachment{
ID: factory.Sonyflake.NextID(),
OwnerID: currentUserID,
Name: strings.TrimSpace(name),
Kind: types.RecordAttachment,
ID: factory.Sonyflake.NextID(),
NamespaceID: namespaceID,
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) {
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")
}
@@ -205,7 +232,7 @@ func (svc *attachment) create(name string, size int64, fh io.ReadSeeker, att *ty
})
}
func (svc *attachment) extractMimetype(file io.ReadSeeker) (mimetype string, err error) {
func (svc attachment) extractMimetype(file io.ReadSeeker) (mimetype string, err error) {
if _, err = file.Seek(0, 0); err != nil {
return
}
@@ -222,7 +249,7 @@ func (svc *attachment) extractMimetype(file io.ReadSeeker) (mimetype string, err
return http.DetectContentType(buf), nil
}
func (svc *attachment) processImage(original io.ReadSeeker, att *types.Attachment) (err error) {
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
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"context"
"github.com/titpetric/factory"
"github.com/crusttech/crust/compose/internal/repository"
"github.com/crusttech/crust/compose/types"
)
type (
chart struct {
db *factory.DB
ctx context.Context
prmSvc PermissionsService
chartRepo repository.ChartRepository
}
ChartService interface {
With(ctx context.Context) ChartService
FindByID(namespaceID, chartID uint64) (*types.Chart, error)
Find(filter types.ChartFilter) (set types.ChartSet, f types.ChartFilter, err error)
Create(chart *types.Chart) (*types.Chart, error)
Update(chart *types.Chart) (*types.Chart, error)
DeleteByID(namespaceID, chartID uint64) error
}
)
func Chart() ChartService {
return (&chart{
prmSvc: DefaultPermissions,
}).With(context.Background())
}
func (svc chart) With(ctx context.Context) ChartService {
db := repository.DB(ctx)
return &chart{
db: db,
ctx: ctx,
prmSvc: svc.prmSvc.With(ctx),
chartRepo: repository.Chart(ctx, db),
}
}
func (svc chart) FindByID(namespaceID, chartID uint64) (c *types.Chart, err error) {
if namespaceID == 0 {
return nil, ErrNamespaceRequired
}
if c, err = svc.chartRepo.FindByID(namespaceID, chartID); err != nil {
return
} else if !svc.prmSvc.CanReadChart(c) {
return nil, ErrNoReadPermissions.withStack()
}
return
}
func (svc chart) Find(filter types.ChartFilter) (set types.ChartSet, f types.ChartFilter, err error) {
set, f, err = svc.chartRepo.Find(filter)
if err != nil {
return
}
set, _ = set.Filter(func(m *types.Chart) (bool, error) {
return svc.prmSvc.CanReadChart(m), nil
})
return
}
func (svc chart) Create(mod *types.Chart) (c *types.Chart, err error) {
if !svc.prmSvc.CanCreateChart(crmNamespace()) {
return nil, ErrNoCreatePermissions.withStack()
}
return svc.chartRepo.Create(mod)
}
func (svc chart) Update(mod *types.Chart) (c *types.Chart, err error) {
if mod.ID == 0 {
return nil, ErrInvalidID.withStack()
}
if c, err = svc.chartRepo.FindByID(mod.NamespaceID, mod.ID); err != nil {
return
}
if isStale(mod.UpdatedAt, c.UpdatedAt, c.CreatedAt) {
return nil, ErrStaleData.withStack()
}
if !svc.prmSvc.CanUpdateChart(c) {
return nil, ErrNoUpdatePermissions.withStack()
}
c.Config = mod.Config
c.Name = mod.Name
return svc.chartRepo.Update(c)
}
func (svc chart) DeleteByID(namespaceID, chartID uint64) error {
if namespaceID == 0 {
return ErrNamespaceRequired.withStack()
}
if c, err := svc.chartRepo.FindByID(namespaceID, chartID); err != nil {
return err
} else if !svc.prmSvc.CanDeleteChart(c) {
return ErrNoDeletePermissions.withStack()
}
return svc.chartRepo.DeleteByID(namespaceID, chartID)
}
+87
View File
@@ -0,0 +1,87 @@
// +build integration
package service
import (
"context"
"testing"
"github.com/pkg/errors"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/test"
systemTypes "github.com/crusttech/crust/system/types"
)
func TestChart(t *testing.T) {
ctx := context.WithValue(context.Background(), "testing", true)
user := &systemTypes.User{
ID: 1337,
Name: "John Crm Doe",
Username: "johndoe",
}
// Set Identity (required for permission checks).
ctx = auth.SetIdentityToContext(ctx, user)
ns1, _ := createTestNamespaces(ctx, t)
svc := Chart().With(ctx)
// the chart object we're working with
chart := &types.Chart{
NamespaceID: ns1.ID,
Name: "Test",
}
{
{
m, err := svc.Update(chart)
test.Assert(t, m == nil, "Expected empty return for invalid update, got %#v", m)
test.Assert(t, err != nil, "Expected error when updating invalid content")
}
// create chart
m, err := svc.Create(chart)
test.Assert(t, err == nil, "Error when creating chart: %+v", err)
test.Assert(t, m.ID > 0, "Expected auto generated ID")
{
_, err := svc.Create(chart)
test.Assert(t, err == nil, "Unexpected error when creating chart, %+v", err)
}
// fetch created chart
{
ms, err := svc.FindByID(m.NamespaceID, m.ID)
test.Assert(t, err == nil, "Error when retrieving chart by id: %+v", err)
test.Assert(t, ms.ID == m.ID, "Expected ID from database to match, %+v", errors.Errorf("%d != %d", m.ID, ms.ID))
test.Assert(t, ms.Name == m.Name, "Expected Name from database to match, %+v", errors.Errorf("%s != %s", m.Name, ms.Name))
}
// update created chart
{
m.UpdatedAt = nil
m.Name = "Updated test"
_, err := svc.Update(m)
test.Assert(t, err == nil, "Error when updating chart, %+v", err)
}
// re-fetch chart
{
ms, err := svc.FindByID(m.NamespaceID, m.ID)
test.Assert(t, err == nil, "Error when retrieving chart by id: %+v", err)
test.Assert(t, ms.ID == m.ID, "re-fetch: Expected ID from database to match, %d != %d", m.ID, ms.ID)
test.Assert(t, ms.Name == m.Name, "Expected Name from database to match, %s != %s", m.Name, ms.Name)
}
// delete chart
{
err := svc.DeleteByID(m.NamespaceID, m.ID)
test.Assert(t, err == nil, "Error when deleting chart by id: %+v", err)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package service
import (
"github.com/pkg/errors"
)
type (
serviceError string
)
const (
ErrInvalidID serviceError = "InvalidID"
ErrStaleData serviceError = "StaleData"
ErrNoCreatePermissions serviceError = "NoCreatePermissions"
ErrNoReadPermissions serviceError = "NoReadPermissions"
ErrNoUpdatePermissions serviceError = "NoUpdatePermissions"
ErrNoDeletePermissions serviceError = "NoDeletePermissions"
ErrNamespaceRequired serviceError = "NamespaceRequired"
ErrNotImplemented serviceError = "NotImplemented"
)
func (e serviceError) Error() string {
return e.String()
}
func (e serviceError) String() string {
return "crust.compose.service." + string(e)
}
func (e serviceError) withStack() error {
return errors.WithStack(e)
}
@@ -3,6 +3,7 @@
package service
import (
"context"
"log"
"os"
"testing"
@@ -10,7 +11,9 @@ import (
"github.com/namsral/flag"
"github.com/titpetric/factory"
crmMigrate "github.com/crusttech/crust/crm/db"
composeMigrate "github.com/crusttech/crust/compose/db"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/test"
systemMigrate "github.com/crusttech/crust/system/db"
systemService "github.com/crusttech/crust/system/service"
)
@@ -25,7 +28,7 @@ func TestMain(m *testing.M) {
flag.Parse()
factory.Database.Add("default", dsn)
factory.Database.Add("crm", dsn)
factory.Database.Add("compose", dsn)
factory.Database.Add("system", dsn)
db := factory.Database.MustGet()
@@ -36,14 +39,14 @@ func TestMain(m *testing.M) {
log.Printf("Error running migrations: %+v\n", err)
return
}
if err := crmMigrate.Migrate(db); err != nil {
if err := composeMigrate.Migrate(db); err != nil {
log.Printf("Error running migrations: %+v\n", err)
return
}
// clean up tables
{
for _, name := range []string{"crm_chart", "crm_trigger", "crm_module", "crm_module_form", "crm_record", "crm_record_value", "crm_page", "sys_user"} {
for _, name := range []string{"compose_chart", "compose_trigger", "compose_module", "compose_module_form", "compose_record", "compose_record_value", "compose_page", "sys_user"} {
_, err := db.Exec("truncate " + name)
if err != nil {
panic("Error when clearing " + name + ": " + err.Error())
@@ -56,3 +59,15 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
func createTestNamespaces(ctx context.Context, t *testing.T) (ns1 *types.Namespace, ns2 *types.Namespace) {
var err error
ns1, err = Namespace().With(ctx).Create(&types.Namespace{Enabled: true, Name: "TestNamespace"})
test.Assert(t, err == nil, "Error when creating namespace: %+v", err)
ns2, err = Namespace().With(ctx).Create(&types.Namespace{Enabled: true, Name: "TestNamespace"})
test.Assert(t, err == nil, "Error when creating namespace: %+v", err)
return ns1, ns2
}
+151
View File
@@ -0,0 +1,151 @@
package service
import (
"context"
"github.com/titpetric/factory"
"github.com/crusttech/crust/compose/internal/repository"
"github.com/crusttech/crust/compose/types"
)
type (
module struct {
db *factory.DB
ctx context.Context
prmSvc PermissionsService
moduleRepo repository.ModuleRepository
pageRepo repository.PageRepository
}
ModuleService interface {
With(ctx context.Context) ModuleService
FindByID(namespaceID, moduleID uint64) (*types.Module, error)
Find(filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error)
Create(module *types.Module) (*types.Module, error)
Update(module *types.Module) (*types.Module, error)
DeleteByID(namespaceID, moduleID uint64) error
}
)
func Module() ModuleService {
return (&module{
prmSvc: DefaultPermissions,
}).With(context.Background())
}
func (svc *module) With(ctx context.Context) ModuleService {
db := repository.DB(ctx)
return &module{
db: db,
ctx: ctx,
prmSvc: svc.prmSvc.With(ctx),
moduleRepo: repository.Module(ctx, db),
pageRepo: repository.Page(ctx, db),
}
}
func (svc *module) FindByID(namespaceID, moduleID uint64) (m *types.Module, err error) {
if namespaceID == 0 {
return nil, ErrNamespaceRequired
}
if m, err = svc.moduleRepo.FindByID(namespaceID, moduleID); err != nil {
return
} else if !svc.prmSvc.CanReadModule(m) {
return nil, ErrNoReadPermissions.withStack()
}
var ff types.ModuleFieldSet
if ff, err = svc.moduleRepo.FindFields(m.ID); err != nil {
return
} else {
_ = ff.Walk(func(f *types.ModuleField) error {
m.Fields = append(m.Fields, f)
return nil
})
}
return
}
func (svc *module) Find(filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error) {
set, f, err = svc.moduleRepo.Find(filter)
if err != nil {
return
}
set, _ = set.Filter(func(m *types.Module) (bool, error) {
return svc.prmSvc.CanReadModule(m), nil
})
// Preload all fields and update all modules
var ff types.ModuleFieldSet
if ff, err = svc.moduleRepo.FindFields(set.IDs()...); err != nil {
return
} else {
_ = ff.Walk(func(f *types.ModuleField) error {
set.FindByID(f.ModuleID).Fields = append(set.FindByID(f.ModuleID).Fields, f)
return nil
})
}
return
}
func (svc *module) Create(mod *types.Module) (*types.Module, error) {
if mod.NamespaceID == 0 {
return nil, ErrNamespaceRequired.withStack()
}
if !svc.prmSvc.CanCreateModule(crmNamespace()) {
return nil, ErrNoCreatePermissions.withStack()
}
return svc.moduleRepo.Create(mod)
}
func (svc *module) Update(mod *types.Module) (m *types.Module, err error) {
if mod.ID == 0 {
return nil, ErrInvalidID.withStack()
}
if m, err = svc.moduleRepo.FindByID(mod.NamespaceID, mod.ID); err != nil {
return
}
if isStale(mod.UpdatedAt, m.UpdatedAt, m.CreatedAt) {
return nil, ErrStaleData.withStack()
}
if !svc.prmSvc.CanUpdateModule(m) {
return nil, ErrNoUpdatePermissions.withStack()
}
m.Name = mod.Name
m.Meta = mod.Meta
m.Fields = mod.Fields
return svc.moduleRepo.Update(m)
}
func (svc *module) DeleteByID(namespaceID, moduleID uint64) error {
if namespaceID == 0 {
return ErrNamespaceRequired.withStack()
}
if c, err := svc.moduleRepo.FindByID(namespaceID, moduleID); err != nil {
return err
} else if !svc.prmSvc.CanDeleteModule(c) {
return ErrNoDeletePermissions.withStack()
}
return svc.moduleRepo.DeleteByID(namespaceID, moduleID)
}
@@ -6,7 +6,7 @@ import (
"context"
"testing"
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/test"
systemTypes "github.com/crusttech/crust/system/types"
@@ -24,11 +24,14 @@ func TestModule(t *testing.T) {
// Set Identity (required for permission checks).
ctx = auth.SetIdentityToContext(ctx, user)
ns1, _ := createTestNamespaces(ctx, t)
svc := Module().With(ctx)
// the module object we're working with
module := &types.Module{
Name: "Test",
NamespaceID: ns1.ID,
Name: "Test",
Fields: types.ModuleFieldSet{
&types.ModuleField{
Name: "name",
@@ -65,7 +68,7 @@ func TestModule(t *testing.T) {
// fetch created module
{
ms, err := svc.FindByID(m.ID)
ms, err := svc.FindByID(m.NamespaceID, m.ID)
test.Assert(t, err == nil, "Error when retrieving module by id: %+v", err)
test.Assert(t, ms.ID == m.ID, "Expected ID from database to match, %d != %d", m.ID, ms.ID)
test.Assert(t, ms.Name == m.Name, "Expected Name from database to match, %s != %s", m.Name, ms.Name)
@@ -75,6 +78,7 @@ func TestModule(t *testing.T) {
// update created module
{
m.Name = "Updated test"
m.UpdatedAt = nil
_, err := svc.Update(m)
test.Assert(t, err == nil, "Error when updating module, %+v", err)
}
@@ -88,7 +92,7 @@ func TestModule(t *testing.T) {
// re-fetch module
{
ms, err := svc.FindByID(m.ID)
ms, err := svc.FindByID(m.NamespaceID, m.ID)
test.Assert(t, err == nil, "Error when retrieving module by id: %+v", err)
test.Assert(t, ms.ID == m.ID, "Expected ID from database to match, %d != %d", m.ID, ms.ID)
test.Assert(t, ms.Name == m.Name, "Expected Name from database to match, %s != %s", m.Name, ms.Name)
@@ -96,7 +100,7 @@ func TestModule(t *testing.T) {
// fetch all modules
{
ms, err := svc.Find()
ms, _, err := svc.Find(types.ModuleFilter{})
test.Assert(t, err == nil, "Error when retrieving modules: %+v", err)
test.Assert(t, len(ms) >= 1, "Expected at least one module, got %d", len(ms))
prevModuleCount = len(ms)
@@ -104,13 +108,13 @@ func TestModule(t *testing.T) {
// re-fetch module
{
err := svc.DeleteByID(m.ID)
err := svc.DeleteByID(m.NamespaceID, m.ID)
test.Assert(t, err == nil, "Error when deleting module by id: %+v", err)
}
// fetch all modules
{
ms, err := svc.Find()
ms, _, err := svc.Find(types.ModuleFilter{})
test.Assert(t, err == nil, "Error when retrieving modules: %+v", err)
test.Assert(t, len(ms) < prevModuleCount, "Expected modules count to decrease after deletion, %d < %d", len(ms), prevModuleCount)
}
+121
View File
@@ -0,0 +1,121 @@
package service
import (
"context"
"github.com/titpetric/factory"
"github.com/crusttech/crust/compose/internal/repository"
"github.com/crusttech/crust/compose/types"
)
type (
namespace struct {
db *factory.DB
ctx context.Context
prmSvc PermissionsService
namespaceRepo repository.NamespaceRepository
}
NamespaceService interface {
With(ctx context.Context) NamespaceService
FindByID(namespaceID uint64) (*types.Namespace, error)
Find(types.NamespaceFilter) (types.NamespaceSet, types.NamespaceFilter, error)
Create(namespace *types.Namespace) (*types.Namespace, error)
Update(namespace *types.Namespace) (*types.Namespace, error)
DeleteByID(namespaceID uint64) error
}
)
func Namespace() NamespaceService {
return (&namespace{
prmSvc: DefaultPermissions,
}).With(context.Background())
}
func (svc *namespace) With(ctx context.Context) NamespaceService {
db := repository.DB(ctx)
return &namespace{
db: db,
ctx: ctx,
prmSvc: svc.prmSvc.With(ctx),
namespaceRepo: repository.Namespace(ctx, db),
}
}
func (svc *namespace) FindByID(ID uint64) (n *types.Namespace, err error) {
if ID == 0 {
return nil, ErrInvalidID.withStack()
}
if n, err = svc.namespaceRepo.FindByID(ID); err != nil {
return
} else if !svc.prmSvc.CanReadNamespace(n) {
return nil, ErrNoReadPermissions.withStack()
}
return
}
func (svc *namespace) Find(filter types.NamespaceFilter) (set types.NamespaceSet, f types.NamespaceFilter, err error) {
set, f, err = svc.namespaceRepo.Find(filter)
if err != nil {
return
}
set, _ = set.Filter(func(m *types.Namespace) (bool, error) {
return svc.prmSvc.CanReadNamespace(m), nil
})
return
}
func (svc *namespace) Create(mod *types.Namespace) (*types.Namespace, error) {
if !svc.prmSvc.CanCreateNamespace() {
return nil, ErrNoCreatePermissions.withStack()
}
return svc.namespaceRepo.Create(mod)
}
func (svc *namespace) Update(mod *types.Namespace) (m *types.Namespace, err error) {
if mod.ID == 0 {
return nil, ErrInvalidID.withStack()
}
m, err = svc.FindByID(mod.ID)
if err != nil {
return nil, err
}
if isStale(mod.UpdatedAt, m.UpdatedAt, m.CreatedAt) {
return nil, ErrStaleData.withStack()
}
if !svc.prmSvc.CanUpdateNamespace(m) {
return nil, ErrNoUpdatePermissions.withStack()
}
m.Name = mod.Name
m.Slug = mod.Slug
m.Meta = mod.Meta
m.Enabled = mod.Enabled
return svc.namespaceRepo.Update(m)
}
func (svc *namespace) DeleteByID(ID uint64) error {
if m, err := svc.namespaceRepo.FindByID(ID); err != nil {
return err
} else if !svc.prmSvc.CanDeleteNamespace(m) {
return ErrNoDeletePermissions.withStack()
}
return svc.namespaceRepo.DeleteByID(ID)
}
@@ -0,0 +1,93 @@
// +build integration
package service
import (
"context"
"testing"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/test"
systemTypes "github.com/crusttech/crust/system/types"
)
func TestNamespace(t *testing.T) {
ctx := context.WithValue(context.Background(), "testing", true)
user := &systemTypes.User{
ID: 1337,
Name: "John Crm Doe",
Username: "johndoe",
}
// Set Identity (required for permission checks).
ctx = auth.SetIdentityToContext(ctx, user)
svc := Namespace().With(ctx)
// the namespace object we're working with
namespace := &types.Namespace{
Name: "Test",
}
prevNamespaceCount := uint(0)
{
{
m, err := svc.Update(namespace)
test.Assert(t, m == nil, "Expected empty return for invalid update, got %#v", m)
test.Assert(t, err != nil, "Expected error when updating invalid content")
}
// create namespace
m, err := svc.Create(namespace)
test.Assert(t, err == nil, "Error when creating namespace: %+v", err)
test.Assert(t, m.ID > 0, "Expected auto generated ID")
// fetch created namespace
{
ms, err := svc.FindByID(m.ID)
test.Assert(t, err == nil, "Error when retrieving namespace by id: %+v", err)
test.Assert(t, ms.ID == m.ID, "Expected ID from database to match, %d != %d", m.ID, ms.ID)
test.Assert(t, ms.Name == m.Name, "Expected Name from database to match, %s != %s", m.Name, ms.Name)
}
// update created namespace
{
m.Name = "Updated test"
m.UpdatedAt = nil
_, err := svc.Update(m)
test.Assert(t, err == nil, "Error when updating namespace, %+v", err)
}
// re-fetch namespace
{
ms, err := svc.FindByID(m.ID)
test.Assert(t, err == nil, "Error when retrieving namespace by id: %+v", err)
test.Assert(t, ms.ID == m.ID, "Expected ID from database to match, %d != %d", m.ID, ms.ID)
test.Assert(t, ms.Name == m.Name, "Expected Name from database to match, %s != %s", m.Name, ms.Name)
}
// fetch all namespaces
{
_, f, err := svc.Find(types.NamespaceFilter{})
test.Assert(t, err == nil, "Error when retrieving namespaces: %+v", err)
test.Assert(t, f.Count > 0, "Expected at least one namespace, got %d", f.Count)
prevNamespaceCount = f.Count
}
// re-fetch namespace
{
err := svc.DeleteByID(m.ID)
test.Assert(t, err == nil, "Error when deleting namespace by id: %+v", err)
}
// fetch all namespaces
{
_, f, err := svc.Find(types.NamespaceFilter{})
test.Assert(t, err == nil, "Error when retrieving namespaces: %+v", err)
test.Assert(t, f.Count < prevNamespaceCount, "Expected namespaces count to decrease after deletion, %d < %d", f.Count, prevNamespaceCount)
}
}
}
+236
View File
@@ -0,0 +1,236 @@
package service
import (
"context"
"github.com/pkg/errors"
"github.com/titpetric/factory"
"github.com/crusttech/crust/compose/internal/repository"
"github.com/crusttech/crust/compose/types"
)
type (
page struct {
db *factory.DB
ctx context.Context
prmSvc PermissionsService
pageRepo repository.PageRepository
moduleRepo repository.ModuleRepository
}
PageService interface {
With(ctx context.Context) PageService
FindByID(namespaceID, pageID uint64) (*types.Page, error)
FindByModuleID(namespaceID, moduleID uint64) (*types.Page, error)
FindBySelfID(namespaceID, selfID uint64) (pages types.PageSet, f types.PageFilter, err error)
Find(filter types.PageFilter) (set types.PageSet, f types.PageFilter, err error)
Tree(namespaceID uint64) (pages types.PageSet, err error)
Create(page *types.Page) (*types.Page, error)
Update(page *types.Page) (*types.Page, error)
DeleteByID(namespaceID, pageID uint64) error
Reorder(namespaceID, selfID uint64, pageIDs []uint64) error
}
)
const (
ErrModulePageExists serviceError = "ModulePageExists"
)
func Page() PageService {
return (&page{
prmSvc: DefaultPermissions,
}).With(context.Background())
}
func (svc *page) With(ctx context.Context) PageService {
db := repository.DB(ctx)
return &page{
db: db,
ctx: ctx,
prmSvc: svc.prmSvc.With(ctx),
pageRepo: repository.Page(ctx, db),
moduleRepo: repository.Module(ctx, db),
}
}
func (svc *page) FindByID(namespaceID, pageID uint64) (p *types.Page, err error) {
return svc.checkPermissions(svc.pageRepo.FindByID(namespaceID, pageID))
}
func (svc *page) FindByModuleID(namespaceID, moduleID uint64) (p *types.Page, err error) {
return svc.checkPermissions(svc.pageRepo.FindByModuleID(namespaceID, moduleID))
}
func (svc *page) checkPermissions(p *types.Page, err error) (*types.Page, error) {
if err != nil {
return nil, err
} else if !svc.prmSvc.CanReadPage(p) {
return nil, errors.New("not allowed to access this page")
}
return p, err
}
func (svc *page) FindBySelfID(namespaceID, parentID uint64) (pp types.PageSet, f types.PageFilter, err error) {
if namespaceID == 0 {
return nil, f, ErrNamespaceRequired.withStack()
}
return svc.filterPageSetByPermission(svc.pageRepo.Find(types.PageFilter{
NamespaceID: namespaceID,
ParentID: parentID,
// This will enable parentID=0 query
Root: true,
}))
}
func (svc *page) Find(filter types.PageFilter) (set types.PageSet, f types.PageFilter, err error) {
if filter.NamespaceID == 0 {
return nil, f, ErrNamespaceRequired.withStack()
}
return svc.filterPageSetByPermission(svc.pageRepo.Find(filter))
}
func (svc *page) Tree(namespaceID uint64) (pages types.PageSet, err error) {
if namespaceID == 0 {
return nil, ErrNamespaceRequired.withStack()
}
var (
tree types.PageSet
filter = types.PageFilter{
NamespaceID: namespaceID,
}
)
return tree, svc.db.Transaction(func() (err error) {
if pages, _, err = svc.filterPageSetByPermission(svc.pageRepo.Find(filter)); err != nil {
return
}
// No preloading - we do not need (or should have) any modules
// associated with us
_ = pages.Walk(func(p *types.Page) error {
if p.SelfID == 0 {
tree = append(tree, p)
} else if c := pages.FindByID(p.SelfID); c != nil {
if c.Children == nil {
c.Children = types.PageSet{}
}
c.Children = append(c.Children, p)
} else {
// Move orphans to root
p.SelfID = 0
tree = append(tree, p)
}
return nil
})
return nil
})
}
func (svc *page) filterPageSetByPermission(pp types.PageSet, f types.PageFilter, err error) (types.PageSet, types.PageFilter, error) {
if err != nil {
return nil, f, err
}
// @todo Filter-by-permission can/will mess up filter's count & paging...
pp, err = pp.Filter(func(m *types.Page) (bool, error) {
return svc.prmSvc.CanReadPage(m), nil
})
return pp, f, err
}
func (svc *page) Reorder(namespaceID, selfID uint64, pageIDs []uint64) error {
return svc.pageRepo.Reorder(namespaceID, selfID, pageIDs)
}
func (svc *page) Create(mod *types.Page) (p *types.Page, err error) {
mod.ID = 0
if mod.NamespaceID == 0 {
return nil, ErrNamespaceRequired.withStack()
}
if !svc.prmSvc.CanCreatePage(crmNamespace()) {
return nil, ErrNoCreatePermissions.withStack()
}
if err = svc.checkModulePage(mod); err != nil {
return
}
p, err = svc.pageRepo.Create(mod)
return
}
func (svc *page) Update(mod *types.Page) (p *types.Page, err error) {
if mod.ID == 0 {
return nil, ErrInvalidID.withStack()
}
if p, err = svc.pageRepo.FindByID(mod.NamespaceID, mod.ID); err != nil {
return
}
if isStale(mod.UpdatedAt, p.UpdatedAt, p.CreatedAt) {
return nil, ErrStaleData.withStack()
}
if !svc.prmSvc.CanUpdatePage(p) {
return nil, ErrNoUpdatePermissions.withStack()
}
if err = svc.checkModulePage(mod); err != nil {
return
}
p.ModuleID = mod.ModuleID
p.SelfID = mod.SelfID
p.Blocks = mod.Blocks
p.Title = mod.Title
p.Description = mod.Description
p.Visible = mod.Visible
p.Weight = mod.Weight
p, err = svc.pageRepo.Update(p)
return
}
func (svc page) checkModulePage(mod *types.Page) error {
if mod.ModuleID > 0 {
if p, err := svc.pageRepo.FindByModuleID(mod.NamespaceID, mod.ModuleID); err != nil {
if err.Error() != repository.ErrPageNotFound.Error() {
return err
}
} else if p.ID > 0 && mod.ID != p.ID {
return ErrModulePageExists
}
}
return nil
}
func (svc *page) DeleteByID(namespaceID, pageID uint64) error {
if p, err := svc.pageRepo.FindByID(namespaceID, pageID); err != nil {
return errors.Wrap(err, "could not delete page")
} else if !svc.prmSvc.CanDeletePage(p) {
return errors.New("not allowed to delete this page")
}
return svc.pageRepo.DeleteByID(namespaceID, pageID)
}
+143
View File
@@ -0,0 +1,143 @@
// +build integration
package service
import (
"context"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/pkg/errors"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/test"
systemTypes "github.com/crusttech/crust/system/types"
)
func TestPage(t *testing.T) {
ctx := context.WithValue(context.Background(), "testing", true)
user := &systemTypes.User{
ID: 1337,
Name: "John Crm Doe",
Username: "johndoe",
}
// Set Identity (required for permission checks).
ctx = auth.SetIdentityToContext(ctx, user)
ns1, _ := createTestNamespaces(ctx, t)
svc := Page().With(ctx)
// the page object we're working with
var err error
page := &types.Page{
NamespaceID: ns1.ID,
Title: "Test",
ModuleID: 123,
}
(&page.Blocks).Scan([]byte("[]"))
prevPageCount := 0
{
{
m, err := svc.Update(page)
test.Assert(t, m == nil, "Expected empty return for invalid update, got %#v", m)
test.Assert(t, err != nil, "Expected error when updating invalid content")
}
// create page
page, err = svc.Create(page)
test.Assert(t, err == nil, "Error when creating page: %+v", err)
test.Assert(t, page.ID > 0, "Expected auto generated ID")
var firstPageID = page.ID
page.SelfID = page.ID
{
_, err = svc.Create(page)
test.Assert(t, err != nil, "%+v", errors.Errorf("Expected error when creating duplicate moduleID page"))
}
page.ModuleID = 0
{
_, err = svc.Create(page)
test.Assert(t, err == nil, "Unexpected error when creating page, %+v", err)
}
{
_, err = svc.Create(page)
test.Assert(t, err == nil, "Unexpected error when creating page, %+v", err)
}
// fetch created page
{
p, err := svc.FindByID(page.NamespaceID, page.ID)
test.Assert(t, err == nil, "Error when retrieving page by id: %+v", err)
test.Assert(t, p.ID == page.ID, "Expected ID from database to match, %+v", errors.Errorf("%d != %d", page.ID, p.ID))
test.Assert(t, p.Title == page.Title, "Expected Title from database to match, %+v", errors.Errorf("%s != %s", page.Title, p.Title))
}
// update created page
{
page.Title = "Updated test"
page.UpdatedAt = nil
_, err := svc.Update(page)
test.Assert(t, err == nil, "Error when updating page, %+v", err)
}
// re-fetch page
{
p, err := svc.FindByID(page.NamespaceID, page.ID)
test.Assert(t, err == nil, "Error when retrieving page by id: %+v", err)
test.Assert(t, p.ID == page.ID, "re-fetch: Expected ID from database to match, %d != %d", page.ID, p.ID)
test.Assert(t, p.Title == page.Title, "Expected Title from database to match, %s != %s", page.Title, p.Title)
}
// fetch all pages
{
p, _, err := svc.FindBySelfID(page.NamespaceID, 0)
test.Assert(t, err == nil, "Error when retrieving pages: %+v", err)
test.Assert(t, len(p) >= 1, "Expected at least one page, got %d", len(p))
prevPageCount = len(p)
}
// fetch all pages
{
p, _, err := svc.FindBySelfID(page.NamespaceID, firstPageID)
test.Assert(t, err == nil, "Error when retrieving pages: %+v", err)
test.Assert(t, len(p) == 2, "Expected 2 pages with selfID=%d, got %v", page.ID, spew.Sdump(p))
prevPageCount = len(p)
ids := []uint64{p[0].ID, p[1].ID}
{
err := svc.Reorder(page.NamespaceID, firstPageID, ids)
test.Assert(t, err == nil, "Error when reordering pages: %+v", err)
p, _, err = svc.FindBySelfID(page.NamespaceID, firstPageID)
test.Assert(t, err == nil, "Error when retrieving pages: %+v", err)
test.Assert(t, len(p) == 2, "Expected 2 pages with selfID=%d, got %v", page.ID, spew.Sdump(p))
test.Assert(t, p[0].Weight < p[1].Weight, "Expected ascending order, %+v", errors.Errorf("%d < %d", p[0].Weight, p[1].Weight))
}
}
// re-fetch page
{
err := svc.DeleteByID(page.NamespaceID, page.ID)
test.Assert(t, err == nil, "Error when deleting page by id: %+v", err)
}
// fetch all pages
{
p, _, err := svc.FindBySelfID(page.NamespaceID, 0)
test.Assert(t, err == nil, "Error when retrieving pages: %+v", err)
test.Assert(t, len(p) < prevPageCount, "Expected pages count to decrease after deletion, %d < %d", len(p), prevPageCount)
}
}
}
@@ -3,8 +3,8 @@ package service
import (
"context"
"github.com/crusttech/crust/crm/internal/repository"
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/internal/repository"
"github.com/crusttech/crust/compose/types"
internalRules "github.com/crusttech/crust/internal/rules"
systemService "github.com/crusttech/crust/system/service"
)
@@ -27,7 +27,11 @@ type (
Effective() (ee []effectivePermission, err error)
CanAccess() bool
CanCreateNamspace() bool
CanGrant() bool
CanCreateNamespace() bool
CanReadNamespace(r permissionResource) bool
CanUpdateNamespace(r permissionResource) bool
CanDeleteNamespace(r permissionResource) bool
CanCreateModule(r permissionResource) bool
CanReadModule(r permissionResource) bool
CanUpdateModule(r permissionResource) bool
@@ -95,7 +99,7 @@ func (p *permissions) Effective() (ee []effectivePermission, err error) {
ee = append(ee, ep("compose", "access", p.CanAccess()))
ee = append(ee, ep("compose", "grant", p.CanGrant()))
ee = append(ee, ep("compose", "namespace.create", p.CanCreateNamspace()))
ee = append(ee, ep("compose", "namespace.create", p.CanCreateNamespace()))
ee = append(ee, ep("compose:namespace:crm", "module.create", p.CanCreateModule(crmNamespace())))
ee = append(ee, ep("compose:namespace:crm", "chart.create", p.CanCreateChart(crmNamespace())))
@@ -113,13 +117,24 @@ func (p *permissions) CanGrant() bool {
return p.checkAccess(types.PermissionResource, "grant")
}
func (p *permissions) CanCreateNamspace() bool {
func (p *permissions) CanCreateNamespace() bool {
return p.checkAccess(types.PermissionResource, "namespace.create")
}
func (p *permissions) CanCreateModule(ns permissionResource) bool {
// @todo move to func args when namespaces are implemented
return p.checkAccess(ns, "module.create")
func (p *permissions) CanReadNamespace(r permissionResource) bool {
return p.checkAccess(r, "read", p.allow())
}
func (p *permissions) CanUpdateNamespace(r permissionResource) bool {
return p.checkAccess(r, "update")
}
func (p *permissions) CanDeleteNamespace(r permissionResource) bool {
return p.checkAccess(r, "delete")
}
func (p *permissions) CanCreateModule(r permissionResource) bool {
return p.checkAccess(r, "module.create")
}
func (p *permissions) CanReadModule(r permissionResource) bool {
@@ -206,3 +221,9 @@ func (p *permissions) checkAccess(resource permissionResource, operation string,
}
return false
}
func (p permissions) allow() func() internalRules.Access {
return func() internalRules.Access {
return internalRules.Allow
}
}
+295
View File
@@ -0,0 +1,295 @@
package service
import (
"context"
"strconv"
"time"
"github.com/pkg/errors"
"github.com/titpetric/factory"
"github.com/crusttech/crust/compose/internal/repository"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/auth"
systemService "github.com/crusttech/crust/system/service"
)
type (
record struct {
db *factory.DB
ctx context.Context
prmSvc PermissionsService
userSvc systemService.UserService
recordRepo repository.RecordRepository
moduleRepo repository.ModuleRepository
}
RecordService interface {
With(ctx context.Context) RecordService
FindByID(namespaceID, recordID uint64) (*types.Record, error)
Report(namespaceID, moduleID uint64, metrics, dimensions, filter string) (interface{}, error)
Find(filter types.RecordFilter) (set types.RecordSet, f types.RecordFilter, err error)
Create(record *types.Record) (*types.Record, error)
Update(record *types.Record) (*types.Record, error)
DeleteByID(namespaceID, recordID uint64) error
// Fields(module *types.Module, record *types.Record) ([]*types.RecordValue, error)
}
)
func Record() RecordService {
return (&record{
prmSvc: DefaultPermissions,
userSvc: systemService.DefaultUser,
}).With(context.Background())
}
func (svc *record) With(ctx context.Context) RecordService {
db := repository.DB(ctx)
return &record{
db: db,
ctx: ctx,
prmSvc: svc.prmSvc.With(ctx),
userSvc: systemService.User(ctx),
recordRepo: repository.Record(ctx, db),
moduleRepo: repository.Module(ctx, db),
}
}
func (svc *record) FindByID(namespaceID, recordID uint64) (r *types.Record, err error) {
if namespaceID == 0 {
return nil, ErrNamespaceRequired
}
if r, err = svc.recordRepo.FindByID(namespaceID, recordID); err != nil {
return
}
if !svc.prmSvc.CanReadRecord(r) {
return nil, ErrNoReadPermissions.withStack()
}
if err = svc.preloadValues(r); err != nil {
return
}
return
}
func (svc record) loadModule(namespaceID, moduleID uint64) (m *types.Module, err error) {
if m, err = svc.moduleRepo.FindByID(namespaceID, moduleID); err != nil {
return
}
if m.Fields, err = svc.moduleRepo.FindFields(m.ID); err != nil {
return
}
if !svc.prmSvc.CanReadRecord(m) {
return nil, ErrNoReadPermissions.withStack()
}
return
}
func (svc *record) Report(namespaceID, moduleID uint64, metrics, dimensions, filter string) (out interface{}, err error) {
var m *types.Module
if m, err = svc.loadModule(namespaceID, moduleID); err != nil {
return
}
if !svc.prmSvc.CanReadRecord(m) {
return nil, ErrNoReadPermissions.withStack()
}
return svc.recordRepo.Report(m, metrics, dimensions, filter)
}
func (svc *record) Find(filter types.RecordFilter) (set types.RecordSet, f types.RecordFilter, err error) {
var m *types.Module
if m, err = svc.loadModule(filter.NamespaceID, filter.ModuleID); err != nil {
return
}
if !svc.prmSvc.CanReadRecord(m) {
return nil, filter, ErrNoReadPermissions.withStack()
}
set, f, err = svc.recordRepo.Find(m, filter)
if err != nil {
return
}
if err = svc.preloadValues(set...); err != nil {
return
}
return
}
func (svc *record) Create(mod *types.Record) (r *types.Record, err error) {
if mod.NamespaceID == 0 {
return nil, ErrNamespaceRequired
}
var m *types.Module
if m, err = svc.loadModule(mod.NamespaceID, mod.ModuleID); err != nil {
return
}
if !svc.prmSvc.CanCreateRecord(m) {
return nil, ErrNoCreatePermissions.withStack()
}
if err = svc.sanitizeValues(m, mod.Values); err != nil {
return
}
mod.OwnedBy = auth.GetIdentityFromContext(svc.ctx).Identity()
mod.CreatedBy = mod.OwnedBy
mod.CreatedAt = time.Now()
return r, svc.db.Transaction(func() (err error) {
if r, err = svc.recordRepo.Create(mod); err != nil {
return
}
if err = svc.recordRepo.UpdateValues(r.ID, mod.Values); err != nil {
return
}
if err = svc.preloadValues(r); err != nil {
return
}
return
})
}
func (svc *record) Update(mod *types.Record) (r *types.Record, err error) {
if mod.ID == 0 {
return nil, ErrInvalidID.withStack()
}
if mod.NamespaceID == 0 {
return nil, ErrNamespaceRequired
}
var m *types.Module
if m, err = svc.loadModule(mod.NamespaceID, mod.ModuleID); err != nil {
return
}
if r, err = svc.recordRepo.FindByID(mod.NamespaceID, mod.ID); err != nil {
return
}
if !svc.prmSvc.CanUpdateRecord(r) {
return nil, ErrNoUpdatePermissions.withStack()
}
if isStale(mod.UpdatedAt, r.UpdatedAt, r.CreatedAt) {
return nil, ErrStaleData.withStack()
}
if err = svc.sanitizeValues(m, mod.Values); err != nil {
return
}
now := time.Now()
r.UpdatedAt = &now
r.UpdatedBy = auth.GetIdentityFromContext(svc.ctx).Identity()
return r, svc.db.Transaction(func() (err error) {
if r, err = svc.recordRepo.Update(r); err != nil {
return
}
if err = svc.recordRepo.UpdateValues(r.ID, mod.Values); err != nil {
return
}
return
})
}
func (svc *record) DeleteByID(namespaceID, recordID uint64) (err error) {
err = svc.db.Transaction(func() (err error) {
var record *types.Record
if record, err = svc.recordRepo.FindByID(namespaceID, recordID); err != nil {
return errors.Wrap(err, "nonexistent record")
}
now := time.Now()
record.DeletedAt = &now
record.DeletedBy = auth.GetIdentityFromContext(svc.ctx).Identity()
if err = svc.recordRepo.Delete(record); err != nil {
return
}
if err = svc.recordRepo.DeleteValues(record); err != nil {
return
}
return
})
return errors.Wrap(err, "unable to delete record")
}
// Validates and filters record values
func (svc *record) sanitizeValues(module *types.Module, values types.RecordValueSet) (err error) {
// Make sure there are no multi values in a non-multi value fields
err = module.Fields.Walk(func(field *types.ModuleField) error {
if !field.Multi && len(values.FilterByName(field.Name)) > 1 {
return errors.Errorf("more than one value for a single-value field %q", field.Name)
}
return nil
})
if err != nil {
return
}
var places = map[string]uint{}
return values.Walk(func(value *types.RecordValue) (err error) {
var field = module.Fields.FindByName(value.Name)
if field == nil {
return errors.Errorf("no such field %q", value.Name)
}
if field.IsRef() {
if value.Ref, err = strconv.ParseUint(value.Value, 10, 64); err != nil {
return err
}
}
value.Place = places[field.Name]
places[field.Name]++
return nil
})
}
func (svc *record) preloadValues(rr ...*types.Record) error {
if rvs, err := svc.recordRepo.LoadValues(types.RecordSet(rr).IDs()...); err != nil {
return err
} else {
return types.RecordSet(rr).Walk(func(r *types.Record) error {
r.Values = rvs.FilterByRecordID(r.ID)
return nil
})
}
}
@@ -6,7 +6,7 @@ import (
"context"
"testing"
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/test"
systemService "github.com/crusttech/crust/system/service"
@@ -23,6 +23,9 @@ func TestRecord(t *testing.T) {
ctx = auth.SetIdentityToContext(ctx, auth.NewIdentity(user.Identity()))
var err error
ns1, ns2 := createTestNamespaces(ctx, t)
{
userSvc := systemService.TestUser(t, ctx)
_, err := userSvc.Create(user)
@@ -31,8 +34,9 @@ func TestRecord(t *testing.T) {
svc := Record().With(ctx)
module := &types.Module{
Name: "Test",
module1 := &types.Module{
NamespaceID: ns1.ID,
Name: "Test",
Fields: types.ModuleFieldSet{
&types.ModuleField{
Name: "name",
@@ -54,18 +58,27 @@ func TestRecord(t *testing.T) {
},
}
// set up a module
var err error
module, err = Module().With(ctx).Create(module)
test.Assert(t, err == nil, "Error when creating module: %+v", err)
test.Assert(t, module.ID > 0, "Expected auto generated ID")
// set up a module1
module1, err = Module().With(ctx).Create(module1)
test.Assert(t, err == nil, "Error when creating module1: %+v", err)
test.Assert(t, module1.ID > 0, "Expected auto generated ID")
module2 := &types.Module{
NamespaceID: ns2.ID,
Name: "Test Dummy",
Fields: module1.Fields,
}
module2, err = Module().With(ctx).Create(module2)
test.Assert(t, err == nil, "Error when creating module1 in another namespace: %+v", err)
record1 := &types.Record{
ModuleID: module.ID,
NamespaceID: ns1.ID,
ModuleID: module1.ID,
}
record2 := &types.Record{
ModuleID: module.ID,
NamespaceID: ns1.ID,
ModuleID: module1.ID,
Values: types.RecordValueSet{
&types.RecordValue{
Name: "name",
@@ -98,9 +111,19 @@ func TestRecord(t *testing.T) {
},
}
{
// Let's put something to another namespace...
_, err := svc.Create(&types.Record{
NamespaceID: ns2.ID,
ModuleID: module2.ID,
})
test.Assert(t, err == nil, "Error when creating record: %+v", err)
}
// now work with records
{
{
record1.UpdatedAt = nil
m, err := svc.Update(record1)
test.Assert(t, m == nil, "Expected empty return for invalid update, got %#v", m)
test.Assert(t, err != nil, "Expected error when updating invalid record")
@@ -118,7 +141,7 @@ func TestRecord(t *testing.T) {
// fetch created record
{
ms, err := svc.FindByID(m1.ID)
ms, err := svc.FindByID(m1.NamespaceID, m1.ID)
test.Assert(t, err == nil, "Error when retrieving record by id: %+v", err)
test.Assert(t, ms.ID == m1.ID, "Expected ID from database to match, %d != %d", m1.ID, ms.ID)
test.Assert(t, ms.ModuleID == m1.ModuleID, "Expected Module ID from database to match, %d != %d", m1.ModuleID, ms.ModuleID)
@@ -126,13 +149,14 @@ func TestRecord(t *testing.T) {
// update created record
{
m1.UpdatedAt = nil
_, err := svc.Update(m1)
test.Assert(t, err == nil, "Error when updating record, %+v", err)
}
// re-fetch record
{
ms, err := svc.FindByID(m1.ID)
ms, err := svc.FindByID(m1.NamespaceID, m1.ID)
test.Assert(t, err == nil, "Error when retrieving record by id: %+v", err)
test.Assert(t, ms.ID == m1.ID, "Expected ID from database to match, %d != %d", m1.ID, ms.ID)
test.Assert(t, ms.ModuleID == m1.ModuleID, "Expected ID from database to match, %d != %d", m1.ModuleID, ms.ModuleID)
@@ -140,39 +164,40 @@ func TestRecord(t *testing.T) {
// fetch all records
{
mr, err := svc.Find(module.ID, "", "id desc", 0, 20)
mr, f, err := svc.Find(types.RecordFilter{ModuleID: module1.ID, Sort: "id desc"})
test.Assert(t, err == nil, "Error when retrieving records: %+v", err)
test.Assert(t, len(mr.Records) == 2, "Expected two record, got %d", len(mr.Records))
test.Assert(t, mr.Meta.Count == 2, "Expected Meta.Count == 2, got %d", mr.Meta.Count)
test.Assert(t, mr.Meta.Sort == "id desc", "Expected Meta.Sort == id desc, got '%s'", mr.Meta.Sort)
test.Assert(t, mr.Records[0].ModuleID == m1.ModuleID, "Expected record module to match, %d != %d", m1.ModuleID, mr.Records[0].ModuleID)
test.Assert(t, mr.Records[0].ID > mr.Records[1].ID, "Expected order to be descending")
test.Assert(t, len(mr) == 2, "Expected two record, got %d", len(mr))
test.Assert(t, f.Count == 2, "Expected Meta.Count == 2, got %d", f.Count)
test.Assert(t, f.Sort == "id desc", "Expected Meta.Sort == id desc, got '%s'", f.Sort)
test.Assert(t, mr[0].ModuleID == m1.ModuleID, "Expected record module1 to match, %d != %d", m1.ModuleID, mr[0].ModuleID)
test.Assert(t, mr[0].ID > mr[1].ID, "Expected order to be descending")
}
// fetch all records
{
mr, err := svc.Find(module.ID, "", "name asc, email desc", 0, 20)
mr, f, err := svc.Find(types.RecordFilter{ModuleID: module1.ID, Sort: "name asc, email desc"})
test.Assert(t, err == nil, "Error when retrieving records: %+v", err)
test.Assert(t, len(mr.Records) == 2, "Expected two record, got %d", len(mr.Records))
test.Assert(t, mr.Meta.Count == 2, "Expected Meta.Count == 2, got %d", mr.Meta.Count)
test.Assert(t, mr.Meta.Sort == "name asc, email desc", "Expected Meta.Sort == 'name asc, email desc' '%s'", mr.Meta.Sort)
test.Assert(t, mr.Records[0].ModuleID == m1.ModuleID, "Expected record module to match, %d != %d", m1.ModuleID, mr.Records[0].ModuleID)
test.Assert(t, len(mr) == 2, "Expected two record, got %d", len(mr))
test.Assert(t, f.Count == 2, "Expected Meta.Count == 2, got %d", f.Count)
test.Assert(t, f.Sort == "name asc, email desc", "Expected Meta.Sort == 'name asc, email desc' '%s'", f.Sort)
test.Assert(t, mr[0].ModuleID == m1.ModuleID, "Expected record module1 to match, %d != %d", m1.ModuleID, mr[0].ModuleID)
// @todo sort is not stable
// test.Assert(t, mr.Records[0].ID > mr.Records[1].ID, "Expected order to be ascending")
// test.Assert(t, mr[0].ID > mr[1].ID, "Expected order to be ascending")
}
// fetch all records
{
mr, err := svc.Find(module.ID, "", "created_at desc", 0, 20)
mr, f, err := svc.Find(types.RecordFilter{ModuleID: module1.ID, Sort: "created_at desc"})
test.Assert(t, err == nil, "Error when retrieving records: %+v", err)
test.Assert(t, len(mr.Records) == 2, "Expected two record, got %d", len(mr.Records))
test.Assert(t, mr.Meta.Count == 2, "Expected Meta.Count == 2, got %d", mr.Meta.Count)
test.Assert(t, mr.Meta.Sort == "created_at desc", "Expected Meta.Sort == created_at desc, got '%s'", mr.Meta.Sort)
test.Assert(t, mr.Records[0].ModuleID == m1.ModuleID, "Expected record module to match, %d != %d", m1.ModuleID, mr.Records[0].ModuleID)
test.Assert(t, len(mr) == 2, "Expected two record, got %d", len(mr))
test.Assert(t, f.Count == 2, "Expected Meta.Count == 2, got %d", f.Count)
test.Assert(t, f.Sort == "created_at desc", "Expected Meta.Sort == created_at desc, got '%s'", f.Sort)
test.Assert(t, mr[0].ModuleID == m1.ModuleID, "Expected record module1 to match, %d != %d", m1.ModuleID, mr[0].ModuleID)
// @todo sort is not stable
// test.Assert(t, mr.Records[0].ID > mr.Records[1].ID, "Expected order to be ascending")
// test.Assert(t, mr[0].ID > mr[1].ID, "Expected order to be ascending")
}
// fetch all records by query
@@ -180,37 +205,37 @@ func TestRecord(t *testing.T) {
filter := "name='John Doe' AND email='john.doe@example.com'"
sort := "id desc"
mr, err := svc.Find(module.ID, filter, sort, 0, 20)
mr, f, err := svc.Find(types.RecordFilter{ModuleID: module1.ID, Sort: sort, Filter: filter})
test.Assert(t, err == nil, "Error when retrieving records: %+v", err)
test.Assert(t, len(mr.Records) == 1, "Expected one record, got %d", len(mr.Records))
test.Assert(t, mr.Meta.Count == 1, "Expected Meta.Count == 1, got %d", mr.Meta.Count)
test.Assert(t, mr.Meta.Page == 0, "Expected Meta.Page == 0, got %d", mr.Meta.Page)
test.Assert(t, mr.Meta.PerPage == 20, "Expected Meta.PerPage == 20, got %d", mr.Meta.PerPage)
test.Assert(t, mr.Meta.Filter == filter, "Expected Meta.Filter == %q, got %q", filter, mr.Meta.Filter)
test.Assert(t, mr.Meta.Sort == sort, "Expected Meta.Sort == %q, got %q", sort, mr.Meta.Sort)
test.Assert(t, len(mr) == 1, "Expected one record, got %d", len(mr))
test.Assert(t, f.Count == 1, "Expected Meta.Count == 1, got %d", f.Count)
test.Assert(t, f.Page == 0, "Expected Meta.Page == 0, got %d", f.Page)
test.Assert(t, f.PerPage == 50, "Expected Meta.PerPage == 50, got %d", f.PerPage)
test.Assert(t, f.Filter == filter, "Expected Meta.Filter == %q, got %q", filter, f.Filter)
test.Assert(t, f.Sort == sort, "Expected Meta.Sort == %q, got %q", sort, f.Sort)
}
// fetch all records by query
{
mr, err := svc.Find(module.ID, "name='niall'", "id asc", 0, 20)
mr, _, err := svc.Find(types.RecordFilter{ModuleID: module1.ID, Sort: "id asc", Filter: "name='niall'"})
test.Assert(t, err == nil, "Error when retrieving records: %+v", err)
test.Assert(t, len(mr.Records) == 0, "Expected no records, got %d", len(mr.Records))
test.Assert(t, len(mr) == 0, "Expected no records, got %d", len(mr))
}
// delete record
{
err := svc.DeleteByID(m1.ID)
err := svc.DeleteByID(m1.NamespaceID, m1.ID)
test.Assert(t, err == nil, "Error when retrieving record by id: %+v", err)
err = svc.DeleteByID(m2.ID)
err = svc.DeleteByID(m2.NamespaceID, m2.ID)
test.Assert(t, err == nil, "Error when retrieving record by id: %+v", err)
}
// fetch all records
{
mr, err := svc.Find(module.ID, "", "", 0, 20)
mr, _, err := svc.Find(types.RecordFilter{ModuleID: module1.ID})
test.Assert(t, err == nil, "Error when retrieving records: %+v", err)
test.Assert(t, len(mr.Records) == 0, "Expected no record, got %d", len(mr.Records))
test.Assert(t, len(mr) == 0, "Expected no record, got %d", len(mr))
}
}
}
@@ -1,6 +1,8 @@
package service
import (
"time"
"github.com/crusttech/crust/internal/store"
)
@@ -19,6 +21,7 @@ var (
DefaultNotification NotificationService
DefaultPermissions PermissionsService
DefaultAttachment AttachmentService
DefaultNamespace NamespaceService
)
func Init() error {
@@ -35,6 +38,21 @@ func Init() error {
DefaultChart = Chart()
DefaultNotification = Notification()
DefaultAttachment = Attachment(fs)
DefaultNamespace = Namespace()
return nil
}
// Data is stale when new date does not match updatedAt or createdAt (before first update)
func isStale(new *time.Time, updatedAt *time.Time, createdAt time.Time) bool {
if new == nil {
// Change to true for stale-data-check
return false
}
if updatedAt != nil {
return !new.Equal(*updatedAt)
}
return new.Equal(createdAt)
}
+129
View File
@@ -0,0 +1,129 @@
package service
import (
"context"
"github.com/titpetric/factory"
"github.com/crusttech/crust/compose/internal/repository"
"github.com/crusttech/crust/compose/types"
)
type (
trigger struct {
db *factory.DB
ctx context.Context
prmSvc PermissionsService
triggerRepo repository.TriggerRepository
}
TriggerService interface {
With(ctx context.Context) TriggerService
FindByID(namespaceID, triggerID uint64) (*types.Trigger, error)
Find(filter types.TriggerFilter) (set types.TriggerSet, f types.TriggerFilter, err error)
Create(trigger *types.Trigger) (*types.Trigger, error)
Update(trigger *types.Trigger) (*types.Trigger, error)
DeleteByID(namespaceID, triggerID uint64) error
}
)
func Trigger() TriggerService {
return (&trigger{
prmSvc: DefaultPermissions,
}).With(context.Background())
}
func (svc trigger) With(ctx context.Context) TriggerService {
db := repository.DB(ctx)
return &trigger{
db: db,
ctx: ctx,
prmSvc: svc.prmSvc.With(ctx),
triggerRepo: repository.Trigger(ctx, db),
}
}
func (svc trigger) FindByID(namespaceID, triggerID uint64) (c *types.Trigger, err error) {
if namespaceID == 0 {
return nil, ErrNamespaceRequired
}
if c, err = svc.triggerRepo.FindByID(namespaceID, triggerID); err != nil {
return
} else if !svc.prmSvc.CanReadTrigger(c) {
return nil, ErrNoReadPermissions.withStack()
}
return
}
func (svc trigger) Find(filter types.TriggerFilter) (set types.TriggerSet, f types.TriggerFilter, err error) {
set, f, err = svc.triggerRepo.Find(filter)
if err != nil {
return
}
set, _ = set.Filter(func(m *types.Trigger) (bool, error) {
return svc.prmSvc.CanReadTrigger(m), nil
})
return
}
func (svc trigger) Create(mod *types.Trigger) (c *types.Trigger, err error) {
if mod.NamespaceID == 0 {
return nil, ErrNamespaceRequired.withStack()
}
if !svc.prmSvc.CanCreateTrigger(crmNamespace()) {
return nil, ErrNoCreatePermissions.withStack()
}
return svc.triggerRepo.Create(mod)
}
func (svc trigger) Update(mod *types.Trigger) (c *types.Trigger, err error) {
if mod.ID == 0 {
return nil, ErrInvalidID.withStack()
}
if c, err = svc.triggerRepo.FindByID(mod.NamespaceID, mod.ID); err != nil {
return
}
if isStale(mod.UpdatedAt, c.UpdatedAt, c.CreatedAt) {
return nil, ErrStaleData.withStack()
}
if !svc.prmSvc.CanUpdateTrigger(c) {
return nil, ErrNoUpdatePermissions.withStack()
}
c.Name = mod.Name
c.ModuleID = mod.ModuleID
c.Source = mod.Source
c.Actions = mod.Actions
c.Enabled = mod.Enabled
return svc.triggerRepo.Update(c)
}
func (svc trigger) DeleteByID(namespaceID, triggerID uint64) error {
if namespaceID == 0 {
return ErrNamespaceRequired.withStack()
}
if c, err := svc.triggerRepo.FindByID(namespaceID, triggerID); err != nil {
return err
} else if !svc.prmSvc.CanDeleteTrigger(c) {
return ErrNoDeletePermissions.withStack()
}
return svc.triggerRepo.DeleteByID(namespaceID, triggerID)
}
@@ -8,7 +8,7 @@ import (
"github.com/pkg/errors"
"github.com/crusttech/crust/crm/types"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/auth"
"github.com/crusttech/crust/internal/test"
systemTypes "github.com/crusttech/crust/system/types"
@@ -26,12 +26,15 @@ func TestTrigger(t *testing.T) {
// Set Identity (required for permission checks).
ctx = auth.SetIdentityToContext(ctx, user)
ns1, _ := createTestNamespaces(ctx, t)
svc := Trigger().With(ctx)
// the trigger object we're working with
trigger := &types.Trigger{
Name: "Test",
ModuleID: 123,
NamespaceID: ns1.ID,
Name: "Test",
ModuleID: 123,
}
{
@@ -53,7 +56,7 @@ func TestTrigger(t *testing.T) {
// fetch created trigger
{
ms, err := svc.FindByID(m.ID)
ms, err := svc.FindByID(m.NamespaceID, m.ID)
test.Assert(t, err == nil, "Error when retrieving trigger by id: %+v", err)
test.Assert(t, ms.ID == m.ID, "Expected ID from database to match, %+v", errors.Errorf("%d != %d", m.ID, ms.ID))
test.Assert(t, ms.Name == m.Name, "Expected Name from database to match, %+v", errors.Errorf("%s != %s", m.Name, ms.Name))
@@ -61,6 +64,7 @@ func TestTrigger(t *testing.T) {
// update created trigger
{
m.UpdatedAt = nil
m.Name = "Updated test"
_, err := svc.Update(m)
test.Assert(t, err == nil, "Error when updating trigger, %+v", err)
@@ -68,7 +72,7 @@ func TestTrigger(t *testing.T) {
// re-fetch trigger
{
ms, err := svc.FindByID(m.ID)
ms, err := svc.FindByID(m.NamespaceID, m.ID)
test.Assert(t, err == nil, "Error when retrieving trigger by id: %+v", err)
test.Assert(t, ms.ID == m.ID, "re-fetch: Expected ID from database to match, %d != %d", m.ID, ms.ID)
test.Assert(t, ms.Name == m.Name, "Expected Name from database to match, %s != %s", m.Name, ms.Name)
@@ -76,7 +80,7 @@ func TestTrigger(t *testing.T) {
// delete trigger
{
err := svc.DeleteByID(m.ID)
err := svc.DeleteByID(m.NamespaceID, m.ID)
test.Assert(t, err == nil, "Error when deleting trigger by id: %+v", err)
}
}
+183
View File
@@ -0,0 +1,183 @@
package rest
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"github.com/titpetric/factory/resputil"
"github.com/crusttech/crust/compose/internal/service"
"github.com/crusttech/crust/compose/rest/request"
"github.com/crusttech/crust/compose/types"
"github.com/crusttech/crust/internal/auth"
"github.com/pkg/errors"
)
var _ = errors.Wrap
type (
attachmentPayload struct {
*types.Attachment
}
attachmentSetPayload struct {
Filter types.AttachmentFilter `json:"filter"`
Set []*attachmentPayload `json:"set"`
}
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{
NamespaceID: r.NamespaceID,
Kind: r.Kind,
ModuleID: r.ModuleID,
RecordID: r.RecordID,
FieldName: r.FieldName,
// Filter: r.Filter,
PerPage: r.PerPage,
Page: r.Page,
// Sort: r.Sort,
}
set, filter, err := ctrl.attachment.With(ctx).Find(f)
return ctrl.makeFilterPayload(ctx, set, filter, err)
}
func (ctrl Attachment) Read(ctx context.Context, r *request.AttachmentRead) (interface{}, error) {
a, err := ctrl.attachment.FindByID(r.NamespaceID, r.AttachmentID)
return makeAttachmentPayload(ctx, a, err)
}
func (ctrl Attachment) Delete(ctx context.Context, r *request.AttachmentDelete) (interface{}, error) {
_, err := ctrl.attachment.FindByID(r.NamespaceID, r.AttachmentID)
if err != nil {
return nil, err
}
return resputil.OK(), ctrl.attachment.With(ctx).DeleteByID(r.NamespaceID, r.AttachmentID)
}
func (ctrl Attachment) Original(ctx context.Context, r *request.AttachmentOriginal) (interface{}, error) {
if err := ctrl.isAccessible(r.NamespaceID, r.AttachmentID, r.UserID, r.Sign); err != nil {
return nil, err
}
return ctrl.serve(ctx, r.NamespaceID, r.AttachmentID, false, r.Download)
}
func (ctrl Attachment) Preview(ctx context.Context, r *request.AttachmentPreview) (interface{}, error) {
if err := ctrl.isAccessible(r.NamespaceID, r.AttachmentID, r.UserID, r.Sign); err != nil {
return nil, err
}
return ctrl.serve(ctx, r.NamespaceID, r.AttachmentID, true, false)
}
func (ctrl Attachment) isAccessible(namespaceID, attachmentID, userID uint64, signature string) error {
if userID == 0 {
return errors.New("missing or invalid user ID")
}
if attachmentID == 0 {
return errors.New("missing or invalid attachment ID")
}
if auth.DefaultSigner.Verify(signature, userID, namespaceID, attachmentID) {
return errors.New("missing or invalid signature")
}
return nil
}
func (ctrl Attachment) serve(ctx context.Context, namespaceID, attachmentID uint64, preview, download bool) (interface{}, error) {
return func(w http.ResponseWriter, req *http.Request) {
att, err := ctrl.attachment.With(ctx).FindByID(namespaceID, attachmentID)
if err != nil {
// Simplify error handling for now
w.WriteHeader(http.StatusNotFound)
return
}
var fh io.ReadSeeker
if preview {
fh, err = ctrl.attachment.OpenPreview(att)
} else {
fh, err = ctrl.attachment.OpenOriginal(att)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
name := url.QueryEscape(att.Name)
if download {
w.Header().Add("Content-Disposition", "attachment; filename="+name)
} else {
w.Header().Add("Content-Disposition", "inline; filename="+name)
}
http.ServeContent(w, req, name, att.CreatedAt, fh)
}, nil
}
func (ctrl Attachment) makeFilterPayload(ctx context.Context, aa types.AttachmentSet, f types.AttachmentFilter, err error) (*attachmentSetPayload, error) {
if err != nil {
return nil, err
}
asp := &attachmentSetPayload{Filter: f, Set: make([]*attachmentPayload, len(aa))}
for i := range aa {
asp.Set[i], _ = makeAttachmentPayload(ctx, aa[i], nil)
}
return asp, nil
}
func makeAttachmentPayload(ctx context.Context, a *types.Attachment, err error) (*attachmentPayload, error) {
if err != nil || a == nil {
return nil, err
}
var (
userID = auth.GetIdentityFromContext(ctx).Identity()
signParams = fmt.Sprintf("?sign=%s&userID=%d", auth.DefaultSigner.Sign(userID, a.NamespaceID, a.ID), userID)
preview string
baseURL = fmt.Sprintf("/namespace/%d/attachment/%s/%d/", a.NamespaceID, 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)
}
ap := &attachmentPayload{a}
ap.Url = baseURL + fmt.Sprintf("original/%s", url.PathEscape(a.Name)) + signParams
ap.PreviewUrl = preview + signParams
return ap, nil
}
+126
View File
@@ -0,0 +1,126 @@
package rest
import (
"context"
"github.com/titpetric/factory/resputil"
"github.com/crusttech/crust/compose/internal/service"
"github.com/crusttech/crust/compose/rest/request"
"github.com/crusttech/crust/compose/types"
"github.com/pkg/errors"
)
var _ = errors.Wrap
type (
chartPayload struct {
*types.Chart
CanUpdateChart bool `json:"canUpdateChart"`
CanDeleteChart bool `json:"canDeleteChart"`
}
chartSetPayload struct {
Filter types.ChartFilter `json:"filter"`
Set []*chartPayload `json:"set"`
}
Chart struct {
chart service.ChartService
permissions service.PermissionsService
}
)
func (Chart) New() *Chart {
return &Chart{
chart: service.DefaultChart,
permissions: service.DefaultPermissions,
}
}
func (ctrl Chart) List(ctx context.Context, r *request.ChartList) (interface{}, error) {
f := types.ChartFilter{
NamespaceID: r.NamespaceID,
Query: r.Query,
PerPage: r.PerPage,
Page: r.Page,
}
set, filter, err := ctrl.chart.With(ctx).Find(f)
return ctrl.makeFilterPayload(ctx, set, filter, err)
}
func (ctrl Chart) Create(ctx context.Context, r *request.ChartCreate) (interface{}, error) {
var err error
mod := &types.Chart{
NamespaceID: r.NamespaceID,
Name: r.Name,
Config: r.Config,
}
mod, err = ctrl.chart.With(ctx).Create(mod)
return ctrl.makePayload(ctx, mod, err)
}
func (ctrl Chart) Read(ctx context.Context, r *request.ChartRead) (interface{}, error) {
mod, err := ctrl.chart.With(ctx).FindByID(r.NamespaceID, r.ChartID)
return ctrl.makePayload(ctx, mod, err)
}
func (ctrl Chart) Update(ctx context.Context, r *request.ChartUpdate) (interface{}, error) {
var (
mod = &types.Chart{}
err error
)
mod.ID = r.ChartID
mod.Name = r.Name
mod.Config = r.Config
mod.NamespaceID = r.NamespaceID
mod.UpdatedAt = r.UpdatedAt
mod, err = ctrl.chart.With(ctx).Update(mod)
return ctrl.makePayload(ctx, mod, err)
}
func (ctrl Chart) Delete(ctx context.Context, r *request.ChartDelete) (interface{}, error) {
_, err := ctrl.chart.With(ctx).FindByID(r.NamespaceID, r.ChartID)
if err != nil {
return nil, err
}
return resputil.OK(), ctrl.chart.With(ctx).DeleteByID(r.NamespaceID, r.ChartID)
}
func (ctrl Chart) makePayload(ctx context.Context, c *types.Chart, err error) (*chartPayload, error) {
if err != nil || c == nil {
return nil, err
}
perm := ctrl.permissions.With(ctx)
return &chartPayload{
Chart: c,
CanUpdateChart: perm.CanUpdateChart(c),
CanDeleteChart: perm.CanDeleteChart(c),
}, nil
}
func (ctrl Chart) makeFilterPayload(ctx context.Context, nn types.ChartSet, f types.ChartFilter, err error) (*chartSetPayload, error) {
if err != nil {
return nil, err
}
modp := &chartSetPayload{Filter: f, Set: make([]*chartPayload, len(nn))}
for i := range nn {
modp.Set[i], _ = ctrl.makePayload(ctx, nn[i], nil)
}
return modp, nil
}
@@ -23,13 +23,14 @@ import (
"github.com/go-chi/chi"
"github.com/titpetric/factory/resputil"
"github.com/crusttech/crust/crm/rest/request"
"github.com/crusttech/crust/compose/rest/request"
)
// Internal API interface
type AttachmentAPI interface {
List(context.Context, *request.AttachmentList) (interface{}, error)
Details(context.Context, *request.AttachmentDetails) (interface{}, error)
Read(context.Context, *request.AttachmentRead) (interface{}, error)
Delete(context.Context, *request.AttachmentDelete) (interface{}, error)
Original(context.Context, *request.AttachmentOriginal) (interface{}, error)
Preview(context.Context, *request.AttachmentPreview) (interface{}, error)
}
@@ -37,7 +38,8 @@ type AttachmentAPI interface {
// HTTP API interface
type Attachment struct {
List func(http.ResponseWriter, *http.Request)
Details func(http.ResponseWriter, *http.Request)
Read func(http.ResponseWriter, *http.Request)
Delete func(http.ResponseWriter, *http.Request)
Original func(http.ResponseWriter, *http.Request)
Preview func(http.ResponseWriter, *http.Request)
}
@@ -64,14 +66,34 @@ func NewAttachment(ah AttachmentAPI) *Attachment {
return
}
},
Details: func(w http.ResponseWriter, r *http.Request) {
Read: func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
params := request.NewAttachmentDetails()
params := request.NewAttachmentRead()
if err := params.Fill(r); err != nil {
resputil.JSON(w, err)
return
}
if value, err := ah.Details(r.Context(), params); err != nil {
if value, err := ah.Read(r.Context(), params); err != nil {
resputil.JSON(w, err)
return
} else {
switch fn := value.(type) {
case func(http.ResponseWriter, *http.Request):
fn(w, r)
return
}
resputil.JSON(w, value)
return
}
},
Delete: func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
params := request.NewAttachmentDelete()
if err := params.Fill(r); err != nil {
resputil.JSON(w, err)
return
}
if value, err := ah.Delete(r.Context(), params); err != nil {
resputil.JSON(w, err)
return
} else {
@@ -130,9 +152,10 @@ func NewAttachment(ah AttachmentAPI) *Attachment {
func (ah *Attachment) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) {
r.Group(func(r chi.Router) {
r.Use(middlewares...)
r.Get("/attachment/{kind}/", ah.List)
r.Get("/attachment/{kind}/{attachmentID}", ah.Details)
r.Get("/attachment/{kind}/{attachmentID}/original/{name}", ah.Original)
r.Get("/attachment/{kind}/{attachmentID}/preview.{ext}", ah.Preview)
r.Get("/namespace/{namespaceID}/attachment/{kind}/", ah.List)
r.Get("/namespace/{namespaceID}/attachment/{kind}/{attachmentID}", ah.Read)
r.Delete("/namespace/{namespaceID}/attachment/{kind}/{attachmentID}", ah.Delete)
r.Get("/namespace/{namespaceID}/attachment/{kind}/{attachmentID}/original/{name}", ah.Original)
r.Get("/namespace/{namespaceID}/attachment/{kind}/{attachmentID}/preview.{ext}", ah.Preview)
})
}

Some files were not shown because too many files have changed in this diff Show More