diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da9801a --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,goland,go +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,goland,go + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### GoLand ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf +.idea + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### GoLand Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### VisualStudioCode ### +.vscode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,goland,go +main diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..8a97c07 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,53 @@ +include: + - project: "devops/pena-continuous-integration" + file: "/templates/docker/build-template.gitlab-ci.yml" + - project: "devops/pena-continuous-integration" + file: "/templates/docker/deploy-template.gitlab-ci.yml" + +stages: + - build + - migrate + - deploy + +build-app: + stage: build + extends: .build_template + rules: + - if: "$CI_COMMIT_BRANCH == $STAGING_BRANCH || $CI_COMMIT_BRANCH == $PRODUCTION_BRANCH" + script: + - docker build -t $CI_REGISTRY_IMAGE/$CI_COMMIT_BRANCH:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID --build-arg GITLAB_TOKEN=$GITLAB_TOKEN $CI_PROJECT_DIR + - docker push $CI_REGISTRY_IMAGE/$CI_COMMIT_BRANCH:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + +migrate-staging: + stage: migrate + variables: + STAGING_BRANCH: staging + tags: + - staging + rules: + - if: "$CI_COMMIT_BRANCH == $STAGING_BRANCH" + script: + - apk add git + - git clone https://buildToken:glpat-axA8ttckx3aPf_xd2Dym@penahub.gitlab.yandexcloud.net/backend/quiz/common.git + - ls + - ./tools/migrate -source file://common/dal/schema -database postgres://squiz:Redalert2@10.8.0.5:5433/squiz?sslmode=disable up + +deploy-staging: + stage: deploy + tags: + - staging + extends: .deploy_template + rules: + - if: "$CI_COMMIT_BRANCH == $STAGING_BRANCH" + after_script: + - docker ps -a + +deploy-prod: + stage: deploy + tags: + - prod + extends: .deploy_template + rules: + - if: "$CI_COMMIT_BRANCH == $PRODUCTION_BRANCH" + after_script: + - ls diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..755da34 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM dockerhub.timeweb.cloud/golang:alpine as build +WORKDIR /app +RUN apk add git +COPY . . +ARG GITLAB_TOKEN +ENV GOPRIVATE=penahub.gitlab.yandexcloud.net/backend/penahub_common +RUN git config --global url."https://buildToken:glpat-axA8ttckx3aPf_xd2Dym@penahub.gitlab.yandexcloud.net/".insteadOf "https://penahub.gitlab.yandexcloud.net/" +RUN go mod download +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o amocrm ./cmd/main.go + +FROM penahub.gitlab.yandexcloud.net:5050/devops/dockerhub-backup/alpine as prod +COPY --from=build /app/amocrm . +EXPOSE 1488 +ENV IS_PROD_LOG=false +ENV IS_PROD=false +CMD ["/amocrm"] diff --git a/blueprint.yaml b/blueprint.yaml new file mode 100644 index 0000000..ca4c482 --- /dev/null +++ b/blueprint.yaml @@ -0,0 +1,20 @@ +templateProjectName: amocrm +Description: Service for integration with amocrm + +Template: + path: "./" + +Modules: + logger: + name: zap + env: + vars: + - name: APP_NAME + type: string + default: "{{.ProjectName}}" + openapi: + model_save_path: ./internal/models + controller_save_path: ./internal/controllers + service_save_path: ./internal/service + repository_save_path: ./internal/repository + server_save_path: ./internal/server/http diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..68bc347 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "amocrm/internal/initialize" + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "amocrm/internal/app" + + "go.uber.org/zap" +) + +func main() { + logger, err := zap.NewProduction() + if err != nil { + fmt.Printf("Failed to initialize logger: %v\n", err) + os.Exit(1) + } + + config, err := initialize.LoadConfig() + if err != nil { + logger.Fatal("Failed to load config", zap.Error(err)) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err = app.Run(ctx, *config, logger); err != nil { + logger.Fatal("App exited with error", zap.Error(err)) + } +} diff --git a/database.puml b/database.puml new file mode 100644 index 0000000..70a6875 --- /dev/null +++ b/database.puml @@ -0,0 +1,87 @@ +@startuml Database + +map FieldRule { + QuestionID => **integer** +} + +map UTM { + CreatedAt => **integer** + Deleted => **boolean** + ID => **integer** + Name => **string** + QuizID => **integer** + AccountID => **string** + AmoFieldID => **integer** +} + +map Pipeline { + CreatedAt => **integer** + Deleted => **boolean** + ID => **integer** + IsArchive => **boolean** + Name => **string** + AccountID => **string** + AmoID => **integer** +} + +map Step { + Deleted => **boolean** + ID => **integer** + Name => **string** + PipelineID => **integer** + AccountID => **string** + AmoID => **integer** + Color => **string** + CreatedAt => **integer** +} + +map Tag { + AccountID => **string** + AmoID => **integer** + Color => **string** + CreatedAt => **integer** + Deleted => **boolean** + Entity => **string** + ID => **integer** + Name => **string** +} + +map Field { + CreatedAt => **integer** + Deleted => **boolean** + EntityType => **string** + ID => **integer** + Name => **string** + Type => **string** + AccountID => **string** + AmoID => **integer** + Code => **string** +} + +map User { + Role => **string** + AmoID => **integer** + Group => **string** + Name => **string** + Email => **string** + ID => **integer** + AccountID => **string** + CreatedAt => **integer** + Deleted => **boolean** +} + +map Rule { + CreatedAt => **integer** + Deleted => **boolean** + FieldsRule => **FieldsRule** + PipelineID => **integer** + QuizID => **integer** + StepID => **integer** + UTMs => **[]integer** + AccountID => **string** + PerformerID => **integer** + ID => **integer** +} + + +@enduml \ No newline at end of file diff --git a/deployments/local/docker-compose.yaml b/deployments/local/docker-compose.yaml new file mode 100644 index 0000000..2f80b77 --- /dev/null +++ b/deployments/local/docker-compose.yaml @@ -0,0 +1,12 @@ +services: + postgres: + image: postgres + restart: always + environment: + POSTGRES_PASSWORD: Redalert2 + POSTGRES_USER: squiz + POSTGRES_DB: squiz + app: + image: penahub.gitlab.yandexcloud.net:5050/backend/squiz:latest + ports: + - 1488:1488 diff --git a/deployments/main/docker-compose.yaml b/deployments/main/docker-compose.yaml new file mode 100644 index 0000000..1431123 --- /dev/null +++ b/deployments/main/docker-compose.yaml @@ -0,0 +1,25 @@ +version: "3" +services: + amocrm: + hostname: squiz-amocrm + container_name: squiz-amocrm + image: $CI_REGISTRY_IMAGE/main:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + HTTP_HOST: '0.0.0.0' + HTTP_PORT: 1488 + REDIS_ADDR: '10.8.0.9:6379' + REDIS_PASS: 'Redalert2' + REDIS_DB: 4 + PENA_SOCIAL_AUTH_URL: 'http://10.8.0.8:59344/amocrm/auth' + + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PG_CRED: 'host=10.8.0.9 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + PUBLIC_KEY: $PEM_PUB_USERID + PRIVATE_KEY: $PEM_PRIV_USERID + KAFKA_BROKERS: 10.8.0.8:9092 + KAFKA_TOPIC: "squiz-amocrm" + GRPC_HOST: "0.0.0.0" + REDIRECT_URL: "https://quiz.pena.digital/integrations" + ports: + - 10.8.0.9:1492:1488 diff --git a/deployments/main/staging/docker-compose.yaml b/deployments/main/staging/docker-compose.yaml new file mode 100644 index 0000000..693c593 --- /dev/null +++ b/deployments/main/staging/docker-compose.yaml @@ -0,0 +1,17 @@ +services: + core: + hostname: squiz-core + container_name: squiz-core + image: $CI_REGISTRY_IMAGE/core:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + HUB_ADMIN_URL: 'http://10.6.0.11:59303' + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PORT: 1488 + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PG_CRED: 'host=10.6.0.23 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + AUTH_URL: 'http://10.6.0.11:59300/user' + ports: + - 1488:1488 + diff --git a/deployments/staging/docker-compose.yaml b/deployments/staging/docker-compose.yaml new file mode 100644 index 0000000..0de29d3 --- /dev/null +++ b/deployments/staging/docker-compose.yaml @@ -0,0 +1,25 @@ +version: "3" +services: + amocrm: + hostname: squiz-amocrm + container_name: squiz-amocrm + image: $CI_REGISTRY_IMAGE/staging:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + HTTP_HOST: '0.0.0.0' + HTTP_PORT: 1488 + REDIS_ADDR: '10.8.0.5:6379' + REDIS_PASS: 'Redalert2' + REDIS_DB: 4 + PENA_SOCIAL_AUTH_URL: 'http://10.8.0.6:59344/amocrm/auth' + + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PG_CRED: 'host=10.8.0.5 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + PUBLIC_KEY: $PEM_PUB_USERID + PRIVATE_KEY: $PEM_PRIV_USERID + KAFKA_BROKERS: 10.8.0.6:9092 + KAFKA_TOPIC: "squiz-amocrm" + GRPC_HOST: "0.0.0.0" + REDIRECT_URL: "https://squiz.pena.digital/integrations" + ports: + - 10.8.0.5:1492:1488 diff --git a/deployments/test/docker-compose.yaml b/deployments/test/docker-compose.yaml new file mode 100644 index 0000000..b66713c --- /dev/null +++ b/deployments/test/docker-compose.yaml @@ -0,0 +1,102 @@ +version: '3' +services: + test-postgres: + image: postgres + environment: + POSTGRES_PASSWORD: Redalert2 + POSTGRES_USER: squiz + POSTGRES_DB: squiz + volumes: + - test-postgres:/var/lib/postgresql/data + ports: + - 35432:5432 + networks: + - penatest + healthcheck: + test: pg_isready -U squiz + interval: 2s + timeout: 2s + retries: 10 + +# need update! +# test-pena-auth-service: +# image: penahub.gitlab.yandexcloud.net:5050/pena-services/pena-auth-service:staging.872 +# container_name: test-pena-auth-service +# init: true +# env_file: auth.env.test +# healthcheck: +# test: wget -T1 --spider http://localhost:8000/user +# interval: 2s +# timeout: 2s +# retries: 5 +# environment: +# - DB_HOST=test-pena-auth-db +# - DB_PORT=27017 +# - ENVIRONMENT=staging +# - HTTP_HOST=0.0.0.0 +# - HTTP_PORT=8000 +# - DB_USERNAME=test +# - DB_PASSWORD=test +# - DB_NAME=admin +# - DB_AUTH=admin +# # ports: +# # - 8000:8000 +# depends_on: +# - test-pena-auth-db +# # - pena-auth-migration +# networks: +# - penatest +# +# test-pena-auth-db: +# container_name: test-pena-auth-db +# init: true +# image: "mongo:6.0.3" +# command: mongod --quiet --logpath /dev/null +# volumes: +# - test-mongodb:/data/db +# - test-mongoconfdb:/data/configdb +# environment: +# MONGO_INITDB_ROOT_USERNAME: test +# MONGO_INITDB_ROOT_PASSWORD: test +# # ports: +# # - 27017:27017 +# networks: +# - penatest + + test-minio: + container_name: test-minio + init: true + image: quay.io/minio/minio + volumes: + - test-minio:/data + command: [ "minio", "--quiet", "server", "/data" ] + networks: + - penatest + + test-squiz: + container_name: test-squiz + init: true + build: + context: ../.. + dockerfile: TestsDockerfile + depends_on: + test-postgres: + condition: service_healthy +# test-pena-auth-service: +# condition: service_healthy + # volumes: + # - ./../..:/app:ro + # command: [ "go", "test", "./tests", "-run", "TestFoo" ] + command: [ "go", "test", "-parallel", "1", "./tests" ] + networks: + - penatest + +networks: + penatest: + + +volumes: + test-minio: + test-postgres: + test-mongodb: + test-mongoconfdb: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..110c067 --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module amocrm + +go 1.21.6 + +require ( + github.com/caarlos0/env/v8 v8.0.0 + github.com/go-redis/redis/v8 v8.11.5 + github.com/gofiber/fiber/v2 v2.52.4 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + github.com/stretchr/testify v1.8.4 + github.com/twmb/franz-go v1.16.1 + go.uber.org/zap v1.27.0 + google.golang.org/protobuf v1.33.0 + penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240710173639-ae1b5abeb71f +) + +require ( + github.com/ClickHouse/clickhouse-go v1.5.4 // indirect + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.69 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pierrec/lz4/v4 v4.1.19 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/rs/xid v1.5.0 // indirect + github.com/tealeg/xlsx v1.0.5 // indirect + github.com/themakers/hlog v0.0.0-20191205140925-235e0e4baddf // indirect + github.com/twmb/franz-go/pkg/kmsg v1.7.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.20.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240223054633-6cb3d5ce45b6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d9c390e --- /dev/null +++ b/go.sum @@ -0,0 +1,150 @@ +github.com/ClickHouse/clickhouse-go v1.5.4 h1:cKjXeYLNWVJIx2J1K6H2CqyRmfwVJVY1OV1coaaFcI0= +github.com/ClickHouse/clickhouse-go v1.5.4/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/bkaradzic/go-lz4 v1.0.0 h1:RXc4wYsyz985CkXXeX04y4VnZFGG8Rd43pRaHsOXAKk= +github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4= +github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0= +github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 h1:F1EaeKL/ta07PY/k9Os/UFtwERei2/XzGemhpGnBKNg= +github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM= +github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.69 h1:l8AnsQFyY1xiwa/DaQskY4NXSLA2yrGsW5iD9nRPVS0= +github.com/minio/minio-go/v7 v7.0.69/go.mod h1:XAvOPJQ5Xlzk5o3o/ArO2NMbhSGkimC+bpW/ngRKDmQ= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4/v4 v4.1.19 h1:tYLzDnjDXh9qIxSTKHwXwOYmm9d887Y7Y1ZkyXYHAN4= +github.com/pierrec/lz4/v4 v4.1.19/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE= +github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM= +github.com/themakers/hlog v0.0.0-20191205140925-235e0e4baddf h1:TJJm6KcBssmbWzplF5lzixXl1RBAi/ViPs1GaSOkhwo= +github.com/themakers/hlog v0.0.0-20191205140925-235e0e4baddf/go.mod h1:1FsorU3vnXO9xS9SrhUp8fRb/6H/Zfll0rPt1i4GWaA= +github.com/twmb/franz-go v1.16.1 h1:rpWc7fB9jd7TgmCyfxzenBI+QbgS8ZfJOUQE+tzPtbE= +github.com/twmb/franz-go v1.16.1/go.mod h1:/pER254UPPGp/4WfGqRi+SIRGE50RSQzVubQp6+N4FA= +github.com/twmb/franz-go/pkg/kmsg v1.7.0 h1:a457IbvezYfA5UkiBvyV3zj0Is3y1i8EJgqjJYoij2E= +github.com/twmb/franz-go/pkg/kmsg v1.7.0/go.mod h1:se9Mjdt0Nwzc9lnjJ0HyDtLyBnaBDAd7pCje47OhSyw= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240223054633-6cb3d5ce45b6 h1:oV+/HNX+JPoQ3/GUx08hio7d45WpY0AMGrFs7j70QlA= +penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240223054633-6cb3d5ce45b6/go.mod h1:lTmpjry+8evVkXWbEC+WMOELcFkRD1lFMc7J09mOndM= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240628071842-da12f589207e h1:9wh9ch9UaJcC/b/SCgDWdj7UX1mPK7ko1PBNp5PpH5U= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240628071842-da12f589207e/go.mod h1:nfZkoj8MCYaoP+xiPeUn5D0lIzinUr1qDkNfX0ng9rk= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240628183520-89234a64c7fe h1:KRz7Blk/yniyY1iC5omxS8yZPb/uBEm0HhM6HGhs6Rw= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240628183520-89234a64c7fe/go.mod h1:nfZkoj8MCYaoP+xiPeUn5D0lIzinUr1qDkNfX0ng9rk= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240710173639-ae1b5abeb71f h1:AsazJV1Z1eCCKSTylddZnRp8ziy2YZofv7/AyPqqtXM= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240710173639-ae1b5abeb71f/go.mod h1:nfZkoj8MCYaoP+xiPeUn5D0lIzinUr1qDkNfX0ng9rk= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..5efbe90 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,181 @@ +package app + +import ( + "amocrm/internal/brokers" + "amocrm/internal/controllers" + "amocrm/internal/initialize" + "amocrm/internal/repository" + "amocrm/internal/server/http" + "amocrm/internal/service" + "amocrm/internal/tools" + "amocrm/internal/workers/data_updater" + "amocrm/internal/workers/limiter" + "amocrm/internal/workers/post_deals_worker" + "amocrm/internal/workers/post_fields_worker" + "amocrm/internal/workers/queueUpdater" + "amocrm/internal/workers_methods" + "amocrm/pkg/amoClient" + "amocrm/pkg/closer" + pena_social_auth "amocrm/pkg/pena-social-auth" + "context" + "errors" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "time" + + "go.uber.org/zap" +) + +func Run(ctx context.Context, config initialize.Config, logger *zap.Logger) error { + defer func() { + if r := recover(); r != nil { + logger.Error("Recovered from a panic", zap.Any("error", r)) + } + }() + + logger.Info("App started", zap.Any("config", config)) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + shutdownGroup := closer.NewCloserGroup() + + redisClient, err := initialize.Redis(ctx, config) + if err != nil { + logger.Error("error init redis client", zap.Error(err)) + return err + } + + kafka, err := initialize.KafkaConsumerInit(ctx, config) + if err != nil { + logger.Error("error init kafka consumer", zap.Error(err)) + return err + } + + producer := brokers.NewProducer(brokers.ProducerDeps{ + KafkaClient: kafka, + Logger: logger, + }) + + amoRepo, err := dal.NewAmoDal(ctx, config.PostgresCredentials) + if err != nil { + logger.Error("error init amo repo in common repo", zap.Error(err)) + return err + } + + socialAithClient := pena_social_auth.NewClient(pena_social_auth.Deps{ + PenaSocialAuthURL: config.PenaSocialAuthURL, + Logger: logger, + ReturnURL: config.ReturnURL, + }) + + rateLimiter := limiter.NewRateLimiter(ctx, 6, 1500*time.Millisecond) + + amoClient := amoClient.NewAmoClient(amoClient.AmoDeps{ + Logger: logger, + RedirectionURL: config.ReturnURL, + IntegrationID: config.IntegrationID, + IntegrationSecret: config.IntegrationSecret, + RateLimiter: rateLimiter, + }) + + redisRepo := repository.NewRepository(repository.Deps{ + RedisClient: redisClient, + Logger: logger, + }) + + svc := service.NewService(service.Deps{ + Repository: amoRepo, + Logger: logger, + SocialAuthClient: socialAithClient, + AmoClient: amoClient, + Producer: producer, + }) + + cntrlDeps := controllers.Deps{ + Service: svc, + Logger: logger, + Verify: tools.NewVerify(config.IntegrationSecret, config.IntegrationID), + RedirectURL: config.RedirectURL, + } + + controller := controllers.NewController(cntrlDeps) + + webhookController := controllers.NewWebhookController(cntrlDeps) + + workerMethods := workers_methods.NewWorkersMethods(workers_methods.Deps{ + Repo: amoRepo, + AmoClient: amoClient, + Logger: logger, + }) + + dataUpdater := data_updater.NewDataUpdaterWC(data_updater.Deps{ + Logger: logger, + Producer: producer, + }) + + queUpdater := queueUpdater.NewQueueUpdater(queueUpdater.Deps{ + Logger: logger, + KafkaClient: kafka, + Methods: workerMethods, + }) + + dealsPoster := post_deals_worker.NewPostDealsWC(post_deals_worker.Deps{ + AmoRepo: amoRepo, + AmoClient: amoClient, + RedisRepo: redisRepo, + Logger: logger, + }) + + fieldsPoster := post_fields_worker.NewPostFieldsWC(post_fields_worker.Deps{ + AmoRepo: amoRepo, + AmoClient: amoClient, + RedisRepo: redisRepo, + Logger: logger, + }) + + go dataUpdater.Start(ctx) + go queUpdater.Start(ctx) + go dealsPoster.Start(ctx) + go fieldsPoster.Start(ctx) + + server := http.NewServer(http.ServerConfig{ + Controllers: []http.Controller{ + controller, + webhookController, + }, + }) + + go func() { + if err := server.Start(config.HTTPHost + ":" + config.HTTPPort); err != nil { + logger.Error("Server startup error", zap.Error(err)) + cancel() + } + }() + + server.ListRoutes() + + shutdownGroup.Add(closer.CloserFunc(server.Shutdown)) + shutdownGroup.Add(closer.CloserFunc(amoRepo.Close)) + shutdownGroup.Add(closer.CloserFunc(redisRepo.Close)) + shutdownGroup.Add(closer.CloserFunc(rateLimiter.Stop)) + shutdownGroup.Add(closer.CloserFunc(dataUpdater.Stop)) + shutdownGroup.Add(closer.CloserFunc(queUpdater.Stop)) + shutdownGroup.Add(closer.CloserFunc(dealsPoster.Stop)) + shutdownGroup.Add(closer.CloserFunc(fieldsPoster.Stop)) + + <-ctx.Done() + + timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer timeoutCancel() + if err := shutdownGroup.Call(timeoutCtx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + logger.Error("Shutdown timed out", zap.Error(err)) + } else { + logger.Error("Failed to shutdown services gracefully", zap.Error(err)) + } + return err + } + + logger.Info("Application has stopped") + return nil +} diff --git a/internal/brokers/producer.go b/internal/brokers/producer.go new file mode 100644 index 0000000..d66d17a --- /dev/null +++ b/internal/brokers/producer.go @@ -0,0 +1,45 @@ +package brokers + +import ( + "amocrm/internal/models" + "context" + "encoding/json" + "github.com/twmb/franz-go/pkg/kgo" + "go.uber.org/zap" +) + +type ProducerDeps struct { + KafkaClient *kgo.Client + Logger *zap.Logger +} + +type Producer struct { + kafkaClient *kgo.Client + logger *zap.Logger +} + +func NewProducer(deps ProducerDeps) *Producer { + return &Producer{ + logger: deps.Logger, + kafkaClient: deps.KafkaClient, + } +} + +func (p *Producer) ToKafkaUpdate(ctx context.Context, message models.KafkaMessage) error { + bytes, err := json.Marshal(message) + if err != nil { + p.logger.Error("error marshal message to kafka", zap.Error(err)) + return err + } + + responses := p.kafkaClient.ProduceSync(ctx, &kgo.Record{Value: bytes}) + for _, response := range responses { + if response.Err != nil { + p.logger.Error("failed to send message on update kafka", zap.Error(response.Err)) + return response.Err + } + + } + + return nil +} diff --git a/internal/controllers/fields.go b/internal/controllers/fields.go new file mode 100644 index 0000000..003ac1a --- /dev/null +++ b/internal/controllers/fields.go @@ -0,0 +1,47 @@ +package controllers + +import ( + "errors" + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/middleware" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (c *Controller) GetFieldsWithPagination(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + req, err := extractParams(ctx) + if err != nil { + return err + } + + response, err := c.service.GetFieldsWithPagination(ctx.Context(), req, accountID) + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("fields for this user not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + } + return ctx.Status(fiber.StatusOK).JSON(response) +} + +func (c *Controller) UpdateListCustom(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + //accountID := "654a8909725f47e926f0bebc" + + err := c.service.UpdateListCustom(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + + return ctx.SendStatus(fiber.StatusOK) +} diff --git a/internal/controllers/initial.go b/internal/controllers/initial.go new file mode 100644 index 0000000..832fe65 --- /dev/null +++ b/internal/controllers/initial.go @@ -0,0 +1,79 @@ +package controllers + +import ( + "amocrm/internal/service" + "amocrm/internal/tools" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +type Deps struct { + Service *service.Service + Logger *zap.Logger + Verify *tools.Verify + RedirectURL string +} + +type Controller struct { + service *service.Service + logger *zap.Logger + verify *tools.Verify +} + +func NewController(deps Deps) *Controller { + return &Controller{ + service: deps.Service, + logger: deps.Logger, + verify: deps.Verify, + } +} + +func (c *Controller) Register(router fiber.Router) { + router.Patch("/users", c.UpdateListUsers) + router.Get("/users", c.GettingUserWithPagination) + router.Delete("/account", c.SoftDeleteAccount) + router.Get("/account", c.GetCurrentAccount) + router.Post("/account", c.ConnectAccount) + router.Get("/steps", c.GetStepsWithPagination) + router.Patch("/steps", c.UpdateListSteps) + router.Patch("/pipelines", c.UpdateListPipelines) + router.Get("/pipelines", c.GetPipelinesWithPagination) + router.Patch("/rules/:quizID", c.ChangeQuizSettings) + router.Post("/rules/:quizID", c.SetQuizSettings) + router.Get("/rules/:quizID", c.GettingQuizRules) + router.Get("/tags", c.GetTagsWithPagination) + router.Patch("/tags", c.UpdateListTags) + router.Get("/fields", c.GetFieldsWithPagination) + router.Patch("/fields", c.UpdateListCustom) +} + +func (c *Controller) Name() string { + return "amocrm" +} + +type WebhookController struct { + service *service.Service + logger *zap.Logger + verify *tools.Verify + redirectURL string +} + +func NewWebhookController(deps Deps) *WebhookController { + return &WebhookController{ + service: deps.Service, + logger: deps.Logger, + verify: deps.Verify, + redirectURL: deps.RedirectURL, + } +} + +func (c *WebhookController) Register(router fiber.Router) { + //todo поменять как было GET webhook/create + router.Get("/create", c.WebhookCreate) + //todo поменять как было webhook/delete + router.Delete("/delete", c.WebhookDelete) +} + +func (c *WebhookController) Name() string { + return "webhook" +} diff --git a/internal/controllers/middleware.go b/internal/controllers/middleware.go new file mode 100644 index 0000000..5c0e1de --- /dev/null +++ b/internal/controllers/middleware.go @@ -0,0 +1,37 @@ +package controllers + +import ( + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "strconv" +) + +func extractParams(ctx *fiber.Ctx) (*model.PaginationReq, error) { + pageStr := ctx.Query("page") + sizeStr := ctx.Query("size") + page := 1 + size := 25 + + if pageStr != "" { + pageNew, err := strconv.Atoi(pageStr) + if err != nil { + return nil, ctx.Status(fiber.StatusBadRequest).SendString("Invalid page parameter") + } + page = pageNew + } + + if sizeStr != "" { + sizeNew, err := strconv.Atoi(sizeStr) + if err != nil { + return nil, ctx.Status(fiber.StatusBadRequest).SendString("Invalid size parameter") + } + size = sizeNew + } + + req := model.PaginationReq{ + Page: page, + Size: int32(size), + } + + return &req, nil +} diff --git a/internal/controllers/pipelines.go b/internal/controllers/pipelines.go new file mode 100644 index 0000000..23758dc --- /dev/null +++ b/internal/controllers/pipelines.go @@ -0,0 +1,47 @@ +package controllers + +import ( + "errors" + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/middleware" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (c *Controller) UpdateListPipelines(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + //accountID := "654a8909725f47e926f0bebc" + + err := c.service.UpdateListPipelines(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + + return ctx.SendStatus(fiber.StatusOK) +} + +func (c *Controller) GetPipelinesWithPagination(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + req, err := extractParams(ctx) + if err != nil { + return err + } + + response, err := c.service.GetPipelinesWithPagination(ctx.Context(), req, accountID) + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("pipelines for this user not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + } + return ctx.Status(fiber.StatusOK).JSON(response) +} diff --git a/internal/controllers/rules.go b/internal/controllers/rules.go new file mode 100644 index 0000000..7754e14 --- /dev/null +++ b/internal/controllers/rules.go @@ -0,0 +1,110 @@ +package controllers + +import ( + "errors" + "fmt" + "github.com/gofiber/fiber/v2" + "github.com/lib/pq" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/middleware" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" + "strconv" +) + +func (c *Controller) ChangeQuizSettings(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + quizID := ctx.Params("quizID") + if quizID == "" { + return ctx.Status(fiber.StatusBadRequest).SendString("quizID is nil") + } + + quizIDInt, err := strconv.Atoi(quizID) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("failed convert quizID to int") + } + + //accountID := "64f2cd7a7047f28fdabf6d9e" + + var request model.RulesReq + if err := ctx.BodyParser(&request); err != nil { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request payload"}) + } + + err = c.service.ChangeQuizSettings(ctx.Context(), &request, accountID, quizIDInt) + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("rule not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + } + return ctx.SendStatus(fiber.StatusOK) +} + +func (c *Controller) SetQuizSettings(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + quizID := ctx.Params("quizID") + if quizID == "" { + return ctx.Status(fiber.StatusBadRequest).SendString("quizID is nil") + } + + quizIDInt, err := strconv.Atoi(quizID) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("failed convert quizID to int") + } + + //accountID := "64f2cd7a7047f28fdabf6d9e" + + var request model.RulesReq + if err := ctx.BodyParser(&request); err != nil { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request payload"}) + } + + err = c.service.SetQuizSettings(ctx.Context(), &request, accountID, quizIDInt) + if err != nil { + pqErr, ok := err.(*pq.Error) + if ok && pqErr.Code == "23505" { + return ctx.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("quiz settings already exist for accountID %s and quizID %d", accountID, quizIDInt)) + } + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("not found user for this rule") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + } + return ctx.SendStatus(fiber.StatusOK) +} + +func (c *Controller) GettingQuizRules(ctx *fiber.Ctx) error { + quizID := ctx.Params("quizID") + if quizID == "" { + return ctx.Status(fiber.StatusBadRequest).SendString("quizID is nil") + } + + quizIDInt, err := strconv.Atoi(quizID) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("failed convert quizID to int") + } + + response, err := c.service.GettingQuizRules(ctx.Context(), quizIDInt) + + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("rule not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + } + return ctx.Status(fiber.StatusOK).JSON(response) +} diff --git a/internal/controllers/steps.go b/internal/controllers/steps.go new file mode 100644 index 0000000..0437645 --- /dev/null +++ b/internal/controllers/steps.go @@ -0,0 +1,58 @@ +package controllers + +import ( + "errors" + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/middleware" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" + "strconv" +) + +func (c *Controller) GetStepsWithPagination(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + pipelineIDStr := ctx.Query("pipelineID") + if pipelineIDStr == "" { + return ctx.Status(fiber.StatusBadRequest).SendString("pipeline id is required") + } + + pipelineID, err := strconv.Atoi(pipelineIDStr) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("invalid pipeline id parameter") + } + + req, err := extractParams(ctx) + if err != nil { + return err + } + + response, err := c.service.GetStepsWithPagination(ctx.Context(), req, accountID, pipelineID) + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("steps for this user not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + } + return ctx.Status(fiber.StatusOK).JSON(response) +} + +func (c *Controller) UpdateListSteps(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + //accountID := "654a8909725f47e926f0bebc" + + err := c.service.UpdateListSteps(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + + return ctx.SendStatus(fiber.StatusOK) +} diff --git a/internal/controllers/tags.go b/internal/controllers/tags.go new file mode 100644 index 0000000..2b4e590 --- /dev/null +++ b/internal/controllers/tags.go @@ -0,0 +1,47 @@ +package controllers + +import ( + "errors" + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/middleware" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (c *Controller) GetTagsWithPagination(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + req, err := extractParams(ctx) + if err != nil { + return err + } + + response, err := c.service.GetTagsWithPagination(ctx.Context(), req, accountID) + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("tags for this user not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + } + return ctx.Status(fiber.StatusOK).JSON(response) +} + +func (c *Controller) UpdateListTags(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + //accountID := "654a8909725f47e926f0bebc" + + err := c.service.UpdateListTags(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + + return ctx.SendStatus(fiber.StatusOK) +} diff --git a/internal/controllers/user.go b/internal/controllers/user.go new file mode 100644 index 0000000..030791b --- /dev/null +++ b/internal/controllers/user.go @@ -0,0 +1,93 @@ +package controllers + +import ( + "errors" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/middleware" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (c *Controller) UpdateListUsers(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + //accountID := "654a8909725f47e926f0bebc" + + err := c.service.UpdateListUsers(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + + return ctx.SendStatus(fiber.StatusOK) +} + +func (c *Controller) GettingUserWithPagination(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + req, err := extractParams(ctx) + if err != nil { + return err + } + + response, err := c.service.GettingUserWithPagination(ctx.Context(), req, accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + return ctx.Status(fiber.StatusOK).JSON(response) +} + +func (c *Controller) SoftDeleteAccount(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + err := c.service.SoftDeleteAccount(ctx.Context(), accountID) + if err != nil { + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + } + return ctx.SendStatus(fiber.StatusOK) +} + +func (c *Controller) GetCurrentAccount(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + response, err := c.service.GetCurrentAccount(ctx.Context(), accountID) + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("user not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + } + return ctx.Status(fiber.StatusOK).JSON(response) +} + +func (c *Controller) ConnectAccount(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + //accountID := "64f2cd7a7047f28fdabf6d9e" + + response, err := c.service.ConnectAccount(ctx.Context(), accountID) + if err != nil { + c.logger.Error("error connect account", zap.Error(err)) + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + + return ctx.Status(fiber.StatusOK).JSON(response) +} diff --git a/internal/controllers/webhook.go b/internal/controllers/webhook.go new file mode 100644 index 0000000..29b88ae --- /dev/null +++ b/internal/controllers/webhook.go @@ -0,0 +1,87 @@ +package controllers + +import ( + "amocrm/internal/service" + "amocrm/internal/tools" + "fmt" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + "net/http" + "strconv" +) + +// контроллер на который редиректятся ответы по авторизации в амо +func (c *WebhookController) WebhookCreate(ctx *fiber.Ctx) error { + code := ctx.Query("code") // Authorization 20 минут + referer := ctx.Query("referer") // адрес аккаунта пользователя + state := ctx.Query("state") // строка которая передавалась в соц аус сервисе + fromWidget := ctx.Query("from_widget") + platform := ctx.Query("platform") // ru/global 1/2 + noAccess := ctx.Query("error") + + if noAccess != "" { + return ctx.Status(http.StatusForbidden).SendString("Access denied") + } + + accountID, _, err := tools.DeserializeProtobufMessage(state) + if err != nil { + c.logger.Error("error Deserialize Protobuf Message", zap.Error(err)) + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + + if accountID == "" || code == "" || referer == "" { + c.logger.Error("error required fields do not be nil", zap.Error(err)) + return ctx.Status(fiber.StatusBadRequest).SendString("nil required fields") + } + + req := service.ParamsWebhookCreate{ + Code: code, + Referer: referer, + AccountID: accountID, + FromWidget: fromWidget, + Platform: platform, + } + + err = c.service.WebhookCreate(ctx.Context(), req) + if err != nil { + c.logger.Error("error create webhook", zap.Error(err)) + return ctx.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("Internal Server Error: %v", err.Error())) + } + + return ctx.Redirect(c.redirectURL) +} + +// todo проверить надо +func (c *WebhookController) WebhookDelete(ctx *fiber.Ctx) error { + clientUUID := ctx.Query("client_uuid") + signature := ctx.Query("signature") + amoIDStr := ctx.Query("account_id") + + fmt.Println(clientUUID) + fmt.Println(signature) + fmt.Println(amoIDStr) + + if clientUUID == "" || signature == "" || amoIDStr == "" { + return ctx.Status(fiber.StatusBadRequest).SendString("some nil values") + } + + amoID, err := strconv.Atoi(amoIDStr) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("invalid account_id type") + } + + if !c.verify.CheckIntegrationID(clientUUID) { + return ctx.Status(fiber.StatusUnauthorized).SendString("invalid hook signature") + } + + if !c.verify.VerifySignature(clientUUID, signature, amoID) { + return ctx.Status(fiber.StatusUnauthorized).SendString("invalid hook signature") + } + + err = c.service.WebhookDelete(ctx.Context(), amoID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") + } + + return ctx.SendStatus(fiber.StatusOK) +} diff --git a/internal/initialize/config.go b/internal/initialize/config.go new file mode 100644 index 0000000..f90faf7 --- /dev/null +++ b/internal/initialize/config.go @@ -0,0 +1,41 @@ +package initialize + +import ( + "github.com/caarlos0/env/v8" + "github.com/joho/godotenv" + "log" +) + +type Config struct { + AppName string `env:"APP_NAME" envDefault:"amocrm"` + HTTPHost string `env:"HTTP_HOST" envDefault:"localhost"` + HTTPPort string `env:"HTTP_PORT" envDefault:"8001"` + PostgresCredentials string `env:"PG_CRED" envDefault:"host=localhost port=35432 user=squiz password=Redalert2 dbname=squiz sslmode=disable"` + KafkaBrokers string `env:"KAFKA_BROKERS" envDefault:"localhost:9092"` + KafkaTopic string `env:"KAFKA_TOPIC" envDefault:"test-topic"` + KafkaGroup string `env:"KAFKA_GROUP" envDefault:"amoCRM"` + RedisAddr string `env:"REDIS_ADDR" envDefault:"localhost:6379"` + RedisPassword string `env:"REDIS_PASS" envDefault:"admin"` + RedisDB int `env:"REDIS_DB" envDefault:"2"` + // урл в соц аус сервисе для генерации ссылки для авторизации в амо + PenaSocialAuthURL string `env:"PENA_SOCIAL_AUTH_URL" envDefault:"http://localhost:8000/amocrm/auth"` + // урл на который будет возвращен пользователь после авторизации это webhook/create get + ReturnURL string `env:"RETURN_URL" envDefault:"https://squiz.pena.digital/squiz/amocrm/oauth"` + // id интеграции + IntegrationID string `env:"INTEGRATION_ID" envDefault:"2dbd6329-9be6-41f2-aa5f-964b9e723e49"` + // секрет интеграции + IntegrationSecret string `env:"INTEGRATION_SECRET" envDefault:"tNK3LwL4ovP0OBK4jKDHJ3646PqRJDOKQYgY6P2t6DCuV8LEzDzszTDY0Fhwmzc8"` + //AmoStorageURL string `env:"AMO_STORAGE_URL" envDefault:"https://drive-b.amocrm.ru"` + RedirectURL string `env:"REDIRECT_URL" envDefault:"https://squiz.pena.digital/integrations"` +} + +func LoadConfig() (*Config, error) { + if err := godotenv.Load(); err != nil { + log.Print("No .env file found") + } + var config Config + if err := env.Parse(&config); err != nil { + return nil, err + } + return &config, nil +} diff --git a/internal/initialize/kafka.go b/internal/initialize/kafka.go new file mode 100644 index 0000000..0d7fc6f --- /dev/null +++ b/internal/initialize/kafka.go @@ -0,0 +1,27 @@ +package initialize + +import ( + "context" + "github.com/twmb/franz-go/pkg/kgo" + "time" +) + +func KafkaConsumerInit(ctx context.Context, config Config) (*kgo.Client, error) { + kafkaClient, err := kgo.NewClient( + kgo.SeedBrokers(config.KafkaBrokers), + kgo.ConsumerGroup(config.KafkaGroup), + kgo.DefaultProduceTopic(config.KafkaTopic), + kgo.ConsumeTopics(config.KafkaTopic), + kgo.ConsumeResetOffset(kgo.NewOffset().AfterMilli(time.Now().UnixMilli())), + ) + if err != nil { + return nil, err + } + + err = kafkaClient.Ping(ctx) + if err != nil { + return nil, err + } + + return kafkaClient, nil +} diff --git a/internal/initialize/redis.go b/internal/initialize/redis.go new file mode 100644 index 0000000..d8de002 --- /dev/null +++ b/internal/initialize/redis.go @@ -0,0 +1,21 @@ +package initialize + +import ( + "context" + "github.com/go-redis/redis/v8" +) + +func Redis(ctx context.Context, cfg Config) (*redis.Client, error) { + rdb := redis.NewClient(&redis.Options{ + Addr: cfg.RedisAddr, + Password: cfg.RedisPassword, + DB: cfg.RedisDB, + }) + + status := rdb.Ping(ctx) + if err := status.Err(); err != nil { + return nil, err + } + + return rdb, nil +} diff --git a/internal/models/createContact.go b/internal/models/createContact.go new file mode 100644 index 0000000..c647bf8 --- /dev/null +++ b/internal/models/createContact.go @@ -0,0 +1,72 @@ +package models + +type CreateContactReq struct { + Name string `json:"name"` // Название контакта + FirstName string `json:"first_name"` // Имя контакта + LastName string `json:"last_name"` // Фамилия контакта + ResponsibleUserID int32 `json:"responsible_user_id"` // ID пользователя, ответственного за контакт + CreatedBy int64 `json:"created_by"` // ID пользователя, создавший контакт + UpdatedBy int64 `json:"updated_by"` // ID пользователя, изменивший контакт + CreatedAt int64 `json:"created_at"` // Дата создания контакта, передается в Unix Timestamp + UpdatedAt int64 `json:"updated_at"` // Дата изменения контакта, передается в Unix Timestamp + CustomFieldsValues []FieldsValues `json:"custom_fields_values"` + TagsToAdd []Tag `json:"tags_to_add"` + Embed Embedd `json:"_embedded"` + RequestID string `json:"request_id"` +} + +type ContactResponse struct { + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + } `json:"_links"` + Embedded struct { + Contacts []struct { + ID int32 `json:"id"` + RequestID string `json:"request_id"` + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + } `json:"_links"` + } `json:"contacts"` + } `json:"_embedded"` +} + +type LinkedContactReq struct { + EntityID int32 `json:"entity_id"` // ID главной сущности + ToEntityID int32 `json:"to_entity_id"` // ID связанной сущности + ToEntityType string `json:"to_entity_type"` // Тип связанной сущности (leads, contacts, companies, customers, catalog_elements) + Metadata struct { + //CatalogID int `json:"catalog_id"` // ID каталога + //Quantity int `json:"quantity"` // Количество прикрепленных элементов каталогов + IsMain bool `json:"is_main"` // Является ли контакт главным + //UpdatedBy int `json:"updated_by"` // ID пользователя, от имени которого осуществляется прикрепление + //PriceID int `json:"price_id"` // ID поля типа Цена, которое будет установлено для привязанного элемента в контексте сущности + } `json:"metadata"` +} + +type LinkedContactResponse struct { + TotalItems int `json:"_total_items"` + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + } `json:"_links"` + Embedded struct { + Links []struct { + EntityID int `json:"entity_id"` + EntityType string `json:"entity_type"` + ToEntityID int `json:"to_entity_id"` + ToEntityType string `json:"to_entity_type"` + Metadata struct { + Quantity int `json:"quantity"` + CatalogID int `json:"catalog_id"` + IsMain bool `json:"is_main"` + UpdatedBy int `json:"updated_by"` + PriceID int `json:"price_id"` + } `json:"metadata"` + } `json:"links"` + } `json:"_embedded"` +} diff --git a/internal/models/createDeal.go b/internal/models/createDeal.go new file mode 100644 index 0000000..d8c26ec --- /dev/null +++ b/internal/models/createDeal.go @@ -0,0 +1,226 @@ +package models + +import ( + "encoding/json" + "fmt" +) + +type DealReq struct { + Name string `json:"name"` // название сделки + Price int `json:"price"` // бюджет сделки + StatusID int32 `json:"status_id"` // id статуса (шага в нашем случае) в который добавляется сделка + PipelineID int32 `json:"pipeline_id"` // ID воронки, в которую добавляется сделка + CreatedBy int32 `json:"created_by"` // id пользователя amoid который создает сделку (тот кто подключил интеграцию) + UpdatedBy int `json:"updated_by"` // ID пользователя, изменяющий сделку. При передаче значения 0, сделка будет считаться измененной роботом + ClosedAt int64 `json:"closed_at"` // Дата закрытия сделки, передается в Unix Timestamp + CreatedAt int64 `json:"created_at"` // Дата создания сделки, передается в Unix Timestamp + UpdatedAt int64 `json:"updated_at"` // Дата изменения сделки, передается в Unix Timestamp + LossReasonID *int `json:"loss_reason_id,omitempty"` // ID причины отказа + ResponsibleUserID int32 `json:"responsible_user_id"` // ID пользователя, ответственного за сделку, в нашем случае PerformerID + CustomFieldsValues []FieldsValues `json:"custom_fields_values"` // Массив полей которые заполняются значениями + TagsToAdd []Tag `json:"tags_to_add"` // Массив тегов для добавления + Embed Embedd `json:"_embedded"` + RequestID string `json:"request_id"` +} + +type ValueInterface interface{} + +type FieldsValues struct { + FieldID int `json:"field_id"` + Values []ValueInterface `json:"values"` +} + +type Values struct { + Value string `json:"value"` // пока так пока не понятно +} + +type ValuesFile struct { + Value ValueFile `json:"value"` +} +type ValueFile struct { + FileUUID string `json:"file_uuid"` + VersionUUID string `json:"version_uuid"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` +} + +func (fv *FieldsValues) UnmarshalJSON(data []byte) error { + type Alias FieldsValues + aux := struct { + Alias + FieldID int `json:"field_id"` + Values []json.RawMessage `json:"values"` + }{} + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + fv.FieldID = aux.FieldID + fv.Values = make([]ValueInterface, len(aux.Values)) + + for i, rawVal := range aux.Values { + var v map[string]interface{} + if err := json.Unmarshal(rawVal, &v); err != nil { + return err + } + + if _, ok := v["value"]; !ok { + return fmt.Errorf("missing value in JSON") + } + + var value ValueInterface + if _, ok := v["value"].(map[string]interface{}); ok { + var fileStruct ValuesFile + if err := json.Unmarshal(rawVal, &fileStruct); err != nil { + return err + } + value = fileStruct + } else { + var valValue Values + if err := json.Unmarshal(rawVal, &valValue); err != nil { + return err + } + value = valValue + } + + fv.Values[i] = value + } + + return nil +} + +type Embedd struct { + Tags []Tag `json:"tags"` // Данные тегов, добавляемых к сделке + Contact []Contact `json:"contacts"` // Данные контактов, которые будет прикреплены к сделке + Company []Company `json:"companies"` // Данные компании, которая будет прикреплена к сделке + Source Source `json:"source"` +} + +type Contact struct { + ID int32 `json:"id"` + Name string `json:"first_name"` + ResponsibleUserID int32 `json:"responsible_user_id"` // ID пользователя, ответственного за сделку, в нашем случае PerformerID + CreatedBy int32 `json:"created_by"` // id пользователя amoid который создает сделку (тот кто подключил интеграцию) + UpdatedBy int `json:"updated_by"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` // Дата изменения сделки, передается в Unix Timestamp + CustomFieldsValues []FieldsValues `json:"custom_fields_values"` // Массив полей которые заполняются значениями +} + +type Company struct { + Name string `json:"name"` // Название компании + ResponsibleUserID int32 `json:"responsible_user_id"` // ID пользователя, ответственного за сделку, в нашем случае PerformerID + CreatedBy int32 `json:"created_by"` // id пользователя amoid который создает сделку (тот кто подключил интеграцию) + UpdatedBy int `json:"updated_by"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` // Дата изменения сделки, передается в Unix Timestamp + CustomFieldsValues []FieldsValues `json:"custom_fields_values"` // Массив полей которые заполняются значениями +} + +type Source struct { + ExternalID int `json:"external_id"` // Внешний ID источника + Type string `json:"type"` // Тип источника. Для сделок, добавляемых интеграциями, поддерживается только widget +} + +type DealResp struct { + DealID int32 `json:"id"` // ID сделки + ContactID int `json:"contact_id"` // ID контакта + CompanyID int `json:"company_id"` // ID компании + Merged bool `json:"merged"` // Флаг, который показывает, найден дубль подходящий под условия поиска дублей и произведено объединение или нет + RequestID []string `json:"request_id"` // Массив строк с пользовательскими идентификаторами, которые были переданы с каждой сущностью +} + +type UpdateDealReq struct { + DealID int32 `json:"id"` // ID сделки + CustomFieldsValues []FieldsValues `json:"custom_fields_values"` // Массив полей которые заполняются значениями +} + +type UpdateDealResp struct { + Embedded EmbeddedUpdateDeal `json:"_embedded"` +} + +type EmbeddedUpdateDeal struct { + Leads []struct { + ID int32 `json:"id"` + UpdatedAt int64 `json:"updated_at"` + } +} + +type Customer struct { + Name string `json:"name"` + NextPrice int `json:"next_price"` + NextDate int64 `json:"next_date"` + ResponsibleUserID int32 `json:"responsible_user_id"` + StatusID *int32 `json:"status_id,omitempty"` + Periodicity int `json:"periodicity"` + CreatedBy int `json:"created_by"` + UpdatedBy int `json:"updated_by"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + CustomFields []FieldsValues `json:"custom_fields_values"` + TagsToAdd []Tag `json:"tags_to_add"` + Embed Embedd `json:"_embedded"` + RequestID string `json:"request_id"` +} + +type CustomerResp struct { + Embedded EmbeddedCreateCustomers `json:"_embedded"` +} + +type EmbeddedCreateCustomers struct { + Customers []struct { + ID int32 `json:"id"` + RequestID string `json:"request_id"` + } +} + +type CreateSession struct { + FileName string `json:"file_name"` // обязательное поле + FileSize int64 `json:"file_size"` // обязательное поле + FileUUID string `json:"file_uuid"` // UUID файла, для которого загружается новая версия файла. Если UUID не задан, то будет создан новый файл. + ContentType string `json:"content_type"` // MIME-тип файла + WithPreview bool `json:"with_preview"` // При установке данного флага для файла будет сгенерировано превью +} + +// представляет данные о созданной сессии загрузки файла +type UploadSession struct { + SessionID int `json:"session_id"` + UploadURL string `json:"upload_url"` + MaxFileSize int64 `json:"max_file_size"` + MaxPartSize int64 `json:"max_part_size"` +} + +// представляет информацию о загруженном файле +type UploadedFile struct { + UUID string `json:"uuid"` + Type string `json:"type"` + IsTrashed bool `json:"is_trashed"` + Name string `json:"name"` + SanitizedName string `json:"sanitized_name"` + Size int64 `json:"size"` + SourceID int `json:"source_id"` + VersionUUID string `json:"version_uuid"` + HasMultipleVersions bool `json:"has_multiple_versions"` + CreatedAt int64 `json:"created_at"` + CreatedBy struct { + ID int `json:"id"` + Type string `json:"type"` + } `json:"created_by"` + UpdatedAt int64 `json:"updated_at"` + DeletedAt int64 `json:"deleted_at"` + DeletedBy interface{} `json:"deleted_by"` + Metadata Metadata `json:"metadata"` + Previews []PreviewFile `json:"previews"` +} + +type Metadata struct { + Extension string `json:"extension"` + MIMEType string `json:"mime_type"` +} + +type PreviewFile struct { + DownloadLink string `json:"download_link"` + Width int `json:"width"` + Height int `json:"height"` +} diff --git a/internal/models/createWebHook.go b/internal/models/createWebHook.go new file mode 100644 index 0000000..c62ec75 --- /dev/null +++ b/internal/models/createWebHook.go @@ -0,0 +1,72 @@ +package models + +type CreateWebHookReq struct { + ClientID string `json:"client_id"` // id интеграции + ClientSecret string `json:"client_secret"` // Секрет интеграции + GrantType string `json:"grant_type"` // Тип авторизационных данных (для кода авторизации – authorization_code) + Code string `json:"code"` // Полученный код авторизации + RedirectUrl string `json:"redirect_uri"` // Redirect URI указанный в настройках интеграции. Должен четко совпадать с тем, что указан в настройках +} + +type CreateWebHookResp struct { + TokenType string `json:"token_type"` // Тип токена + ExpiresIn int64 `json:"expires_in"` // ttl в секундах + AccessToken string `json:"access_token"` // Access Token в формате JWT + RefreshToken string `json:"refresh_token"` +} + +type UpdateWebHookReq struct { + ClientID string `json:"client_id"` // id интеграции + ClientSecret string `json:"client_secret"` // Секрет интеграции + GrantType string `json:"grant_type"` // Тип авторизационных данных (для кода авторизации – authorization_code) + RefreshToken string `json:"refresh_token"` // Refresh токен + RedirectUrl string `json:"redirect_uri"` // Redirect URI указанный в настройках интеграции. Должен четко совпадать с тем, что указан в настройках +} + +type WebHookRequest interface { + SetClientID(str string) + SetClientSecret(str string) + GetGrantType() string + SetRedirectURL(str string) + GetToken() string +} + +func (req *CreateWebHookReq) SetClientID(str string) { + req.ClientID = str +} + +func (req *CreateWebHookReq) SetClientSecret(str string) { + req.ClientSecret = str +} + +func (req *CreateWebHookReq) GetGrantType() string { + return req.GrantType +} + +func (req *CreateWebHookReq) SetRedirectURL(str string) { + req.RedirectUrl = str +} + +func (req *CreateWebHookReq) GetToken() string { + return req.Code +} + +func (req *UpdateWebHookReq) SetClientID(str string) { + req.ClientID = str +} + +func (req *UpdateWebHookReq) SetClientSecret(str string) { + req.ClientSecret = str +} + +func (req *UpdateWebHookReq) GetGrantType() string { + return req.GrantType +} + +func (req *UpdateWebHookReq) SetRedirectURL(str string) { + req.RedirectUrl = str +} + +func (req *UpdateWebHookReq) GetToken() string { + return req.RefreshToken +} diff --git a/internal/models/forRedis.go b/internal/models/forRedis.go new file mode 100644 index 0000000..ad3b56c --- /dev/null +++ b/internal/models/forRedis.go @@ -0,0 +1,20 @@ +package models + +type SaveDeal struct { + AnswerID int64 + DealID int32 + AccessToken string + SubDomain string +} + +type MappingDealsData struct { + AnswerID int64 + DealID int32 + LeadFields []FieldsValues + SubDomain string +} + +type ForRestoringData struct { + SaveDeal SaveDeal + LeadFields []FieldsValues +} diff --git a/internal/models/getListFields.go b/internal/models/getListFields.go new file mode 100644 index 0000000..d5432a7 --- /dev/null +++ b/internal/models/getListFields.go @@ -0,0 +1,92 @@ +package models + +import ( + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" +) + +type GetListFieldsReq struct { + Page int `json:"page"` + Limit int `json:"limit"` + //Filter []string `json:"filter"` // пока не понял что это может быть + EntityType model.EntityType `json:"entityType"` +} + +type ResponseGetListFields struct { + TotalItems int `json:"_total_items"` + Page int `json:"_page"` + PageCount int `json:"_page_count"` + Links Links `json:"_links"` + Embedded EmbeddedFields `json:"_embedded"` +} + +type Links struct { + Self SelfLink `json:"self"` + Next SelfLink `json:"next"` + Last SelfLink `json:"last"` +} + +type EmbeddedFields struct { + CustomFields []CustomField `json:"custom_fields"` +} + +type CustomField struct { + ID int `json:"id"` + Name string `json:"name"` + Sort int `json:"sort"` + Code string `json:"code"` + Type string `json:"type"` + Entity_type string `json:"entity_type"` + IsComputed bool `json:"is_computed"` + IsPredefined bool `json:"is_predefined"` + IsDeletable bool `json:"is_deletable"` + IsVisible bool `json:"is_visible"` + IsRequired bool `json:"is_required"` + Settings []interface{} `json:"settings,omitempty"` // проверить что это (array null) + Remind *string `json:"remind,omitempty"` + Currency *string `json:"currency,omitempty"` + Enums []Enum `json:"enums,omitempty"` + Nested []Nested `json:"nested,omitempty"` + IsAPIOnly bool `json:"is_api_only"` + GroupID *string `json:"group_id,omitempty"` + RequiredStatuses []RequiredStatus `json:"required_statuses,omitempty"` + HiddenStatuses []HiddenStatus `json:"hidden_statuses,omitempty"` + ChainedLists []ChainedList `json:"chained_lists,omitempty"` + TrackingCallback string `json:"tracking_callback,omitempty"` + SearchIn *string `json:"search_in,omitempty"` + Links SelfLink `json:"_links"` +} + +type Enum struct { + ID int `json:"id"` + Value string `json:"value"` + Sort int `json:"sort"` + Code *string `json:"code,omitempty"` +} + +type Nested struct { + ID int `json:"id"` + ParentID int `json:"parent_id"` + Value string `json:"value"` + Sort int `json:"sort"` +} + +type RequiredStatus struct { + StatusID int `json:"status_id"` + PipelineID int `json:"pipeline_id"` +} + +type HiddenStatus struct { + StatusID int `json:"status_id"` + PipelineID int `json:"pipeline_id"` +} + +type ChainedList struct { + Title *string `json:"title,omitempty"` + CatalogID int `json:"catalog_id"` + ParentCatalogID int `json:"parent_catalog_id"` +} + +type AddLeadsFields struct { + Type model.FieldType `json:"type"` + Name string `json:"name"` +} diff --git a/internal/models/getListPipelines.go b/internal/models/getListPipelines.go new file mode 100644 index 0000000..aa4bd40 --- /dev/null +++ b/internal/models/getListPipelines.go @@ -0,0 +1,47 @@ +package models + +type PipelineResponse struct { + TotalItems int `json:"_total_items"` + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + } `json:"_links"` + Embedded struct { + Pipelines []Pipeline `json:"pipelines"` + } `json:"_embedded"` +} + +type Pipeline struct { + ID int `json:"id"` + Name string `json:"name"` + Sort int `json:"sort"` + IsMain bool `json:"is_main"` + IsUnsortedOn bool `json:"is_unsorted_on"` + IsArchive bool `json:"is_archive"` + AccountID int `json:"account_id"` + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + } `json:"_links"` + Embedded struct { + Statuses []Status `json:"statuses"` + } `json:"_embedded"` +} + +type Status struct { + ID int `json:"id"` + Name string `json:"name"` + Sort int `json:"sort"` + IsEditable bool `json:"is_editable"` + PipelineID int `json:"pipeline_id"` + Color string `json:"color"` + Type int `json:"type"` + AccountID int `json:"account_id"` + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + } `json:"_links"` +} diff --git a/internal/models/getListSteps.go b/internal/models/getListSteps.go new file mode 100644 index 0000000..494b7c1 --- /dev/null +++ b/internal/models/getListSteps.go @@ -0,0 +1,36 @@ +package models + +type ResponseGetListSteps struct { + TotalItems int `json:"_total_items"` + Embedded EmbeddedSteps `json:"_embedded"` +} + +type EmbeddedSteps struct { + Statuses []Statuses `json:"statuses"` +} + +type Statuses struct { + ID int `json:"id"` + Name string `json:"name"` + Sort int `json:"sort"` + IsEditable bool `json:"is_editable"` + PipelineID int `json:"pipeline_id"` + Color string `json:"color"` + Type int `json:"type"` + AccountID int `json:"account_id"` + Links LinksSelf `json:"_links"` + Descriptions []Descriptions `json:"descriptions"` +} + +type Descriptions struct { + AccountID int `json:"account_id"` + CreatedAt string `json:"created_at"` + CreatedBy int `json:"created_by"` + Description string `json:"description"` + ID int `json:"id"` + Level string `json:"level"` + PipelineID int `json:"pipeline_id"` + StatusID int `json:"status_id"` + UpdatedAt string `json:"updated_at"` + UpdatedBy int `json:"updated_by"` +} diff --git a/internal/models/getListTags.go b/internal/models/getListTags.go new file mode 100644 index 0000000..d10d3db --- /dev/null +++ b/internal/models/getListTags.go @@ -0,0 +1,34 @@ +package models + +import ( + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" +) + +type GetListTagsReq struct { + Page int `json:"page"` + Limit int `json:"limit"` + Filter Filter `json:"filter"` + EntityType model.EntityType `json:"entityType"` +} + +type Filter struct { + Name string `json:"name"` + ID []int `json:"id"` + Query string `json:"query"` +} + +type ResponseGetListTags struct { + Page int `json:"_page"` + Links Links `json:"_links"` + Embedded EmbeddedTags `json:"_embedded"` +} + +type EmbeddedTags struct { + Tags []Tag `json:"tags"` +} + +type Tag struct { + ID int `json:"id"` + Name string `json:"name"` + Color *string `json:"color,omitempty"` +} diff --git a/internal/models/getUserList.go b/internal/models/getUserList.go new file mode 100644 index 0000000..7cde67d --- /dev/null +++ b/internal/models/getUserList.go @@ -0,0 +1,116 @@ +package models + +type RequestGetListUsers struct { + Page int + Limit int +} + +type ResponseGetListUsers struct { + TotalItems int `json:"_total_items"` + Links LinksSelf `json:"_links"` + Embedded EmbeddedGetListUsers `json:"_embedded"` +} + +type EmbeddedGetListUsers struct { + Users []Users `json:"items"` +} + +type SelfLink struct { + Href string `json:"href"` +} + +type LinksSelf struct { + Self SelfLink `json:"self"` +} + +type Users struct { + ID int32 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + UUID string `json:"uuid"` + IsConfirmed bool `json:"is_confirmed"` + ConfirmLinkSentAt int `json:"confirm_link_sent_at"` + Lang string `json:"lang"` + FullName string `json:"full_name"` + Groups []Groups `json:"groups"` + Links SelfLink `json:"_links"` + Rights Rights `json:"_embedded"` +} + +type Rights struct { + Leads `json:"leads"` + Contacts `json:"contacts"` + Companies `json:"companies"` + Tasks `json:"tasks"` + MailAccess bool `json:"mail_access"` + CatalogAccess bool `json:"catalog_access"` + StatusRights []StatusRights `json:"status_rights"` + IsAdmin bool `json:"is_admin"` + IsFree bool `json:"is_free"` + IsActive bool `json:"is_active"` + GroupID int `json:"group_id,omitempty"` + RoleID int `json:"role_id,omitempty"` + Role string `json:"role,omitempty"` +} + +type Leads struct { + View string `json:"view"` + Edit string `json:"edit"` + Add string `json:"add"` + Delete string `json:"delete"` + Export string `json:"export"` +} + +type Contacts struct { + View string `json:"view"` + Edit string `json:"edit"` + Add string `json:"add"` + Delete string `json:"delete"` + Export string `json:"export"` +} + +type Companies struct { + View string `json:"view"` + Edit string `json:"edit"` + Add string `json:"add"` + Delete string `json:"delete"` + Export string `json:"export"` +} + +type Tasks struct { + Edit string `json:"edit"` + Delete string `json:"delete"` +} + +type StatusRights struct { + EntityType string `json:"entity_type"` + PipelineID int `json:"pipeline_id"` + StatusID int `json:"status_id"` + Rights RightsGetListUsers `json:"rights"` +} + +type RightsGetListUsers struct { + View string `json:"view"` + Edit string `json:"edit"` + Delete string `json:"delete"` +} + +type Roles struct { + ID int `json:"id"` + Name string `json:"name"` + Links SelfLink `json:"_links"` +} + +type Groups struct { + ID int `json:"id"` + Name string `json:"name"` +} + +//type Embedded struct { +// Roles []Roles `json:"roles"` +// Groups []Groups `json:"groups"` +//} + +type Embedded struct { + Rights Rights `json:"rights"` +} diff --git a/internal/models/kafkaMess.go b/internal/models/kafkaMess.go new file mode 100644 index 0000000..2371f1d --- /dev/null +++ b/internal/models/kafkaMess.go @@ -0,0 +1,32 @@ +package models + +import "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + +type KafkaMessage struct { + AccountID string + AuthCode string + RefererURL string + Type MessageType + Rule KafkaRule +} + +type KafkaRule struct { + QuizID int32 + PerformerID int32 // айдишник ответственного за сделку + PipelineID int32 // айдишник воронки + StepID int32 // айдишник этапа + Fieldsrule model.Fieldsrule +} + +type MessageType string + +const ( + UsersUpdate MessageType = "users" + PipelinesUpdate MessageType = "pipelines" + FieldsUpdate MessageType = "fields" + TagsUpdate MessageType = "tags" + UserCreate MessageType = "userCreate" + AllDataUpdate MessageType = "allDataUpdate" + RuleCheck MessageType = "ruleCheck" + UserReLogin MessageType = "userReLogin" +) diff --git a/internal/models/userInfo.go b/internal/models/userInfo.go new file mode 100644 index 0000000..8a5aa32 --- /dev/null +++ b/internal/models/userInfo.go @@ -0,0 +1,160 @@ +package models + +type AmocrmUserInformation struct { + //ID аккаунта + ID int32 `json:"id" bson:"id"` + //Название аккаунта + Name string `json:"name" bson:"name"` + //Субдомен аккаунта + Subdomain string `json:"subdomain" bson:"subdomain"` + //ID текущего пользователя + CurrentUserID int `json:"current_user_id" bson:"current_user_id"` + //Страна, указанная в настройках аккаунта + Country string `json:"country" bson:"country"` + //Режим покупателей. Возможные варианты: unavailable (функционал недоступен), disabled (функцонал отключен), segments (сегментация), dynamic (deprecated), periodicity (периодические покупки) + CustomersMode string `json:"customers_mode" bson:"customers_mode"` + //Включен ли функционал “Неразобранного” в аккаунте + IsUnsortedOn bool `json:"is_unsorted_on" bson:"is_unsorted_on"` + //Включен ли функционал причин отказа + IsLossReasonEnabled bool `json:"is_loss_reason_enabled" bson:"is_loss_reason_enabled"` + //Включен ли функционал Типовых вопросов (доступен только на профессиональном тарифе) + IsHelpbotEnabled bool `json:"is_helpbot_enabled" bson:"is_helpbot_enabled"` + //Является ли данный аккаунт техническим + IsTechnicalAccount bool `json:"is_technical_account" bson:"is_technical_account"` + //Порядок отображения имен контактов (1 – Имя, Фамилия; 2 – Фамилия, Имя) + ContactNameDisplayOrder int `json:"contact_name_display_order" bson:"contact_name_display_order"` + //Требуется GET параметр with. Уникальный идентификатор аккаунта для работы с сервисом чатов amoJo + AmojoID string `json:"amojo_id" bson:"amojo_id"` + //Требуется GET параметр with. Адрес сервиса файлов для конкретного аккаунта + DriveUrl string `json:"drive_url" bson:"drive_url"` + //Требуется GET параметр with. Текущая версия amoCRM + Version int `json:"version" bson:"version"` + UUID string `json:"uuid" bson:"uuid"` + IsApiFilterEnabled bool `json:"is_api_filter_enabled" bson:"is_api_filter_enabled"` + Links struct { + Self struct { + Href string `json:"href" bson:"href"` + } `json:"self" bson:"self"` + } `json:"_links" bson:"_links"` + Embedded struct { + AmojoRights struct { + CanDirect bool `json:"can_direct" bson:"can_direct"` + CanCreateGroups bool `json:"can_create_groups" bson:"can_create_groups"` + } `json:"amojo_rights" bson:"amojo_rights"` + //Требуется GET параметр with. Массив объектов групп пользователей аккаунта + UsersGroups []struct { + ID int `json:"id" bson:"id"` + Name string `json:"name" bson:"name"` + UUID interface{} `json:"uuid" bson:"uuid"` + } `json:"users_groups" bson:"users_groups"` + TaskTypes []struct { + ID int `json:"id" bson:"id"` + Name string `json:"name" bson:"name"` + Color interface{} `json:"color" bson:"color"` + IconID interface{} `json:"icon_id" bson:"icon_id"` + Code string `json:"code" bson:"code"` + } `json:"task_types" bson:"task_types"` + // Требуется GET параметр with. Настройки названия сущностей + EntityNames struct { + Leads struct { + Ru struct { + Gender string `json:"gender" bson:"gender"` + PluralForm struct { + Dative string `json:"dative" bson:"dative"` + Default string `json:"default" bson:"default"` + Genitive string `json:"genitive" bson:"genitive"` + Accusative string `json:"accusative" bson:"accusative"` + Instrumental string `json:"instrumental" bson:"instrumental"` + Prepositional string `json:"prepositional" bson:"prepositional"` + } `json:"plural_form" bson:"plural_form"` + SingularForm struct { + Dative string `json:"dative" bson:"dative"` + Default string `json:"default" bson:"default"` + Genitive string `json:"genitive" bson:"genitive"` + Accusative string `json:"accusative" bson:"accusative"` + Instrumental string `json:"instrumental" bson:"instrumental"` + Prepositional string `json:"prepositional" bson:"prepositional"` + } `json:"singular_form" bson:"singular_form"` + } `json:"ru" bson:"ru"` + En struct { + SingularForm struct { + Default string `json:"default" bson:"default"` + } `json:"singular_form" bson:"singular_form"` + PluralForm struct { + Default string `json:"default" bson:"default"` + } `json:"plural_form" bson:"plural_form"` + Gender string `json:"gender" bson:"gender"` + } `json:"en" bson:"en"` + Es struct { + SingularForm struct { + Default string `json:"default" bson:"default"` + } `json:"singular_form" bson:"singular_form"` + PluralForm struct { + Default string `json:"default" bson:"default"` + } `json:"plural_form" bson:"plural_form"` + Gender string `json:"gender" bson:"gender"` + } `json:"es" bson:"es"` + } `json:"leads" bson:"leads"` + } `json:"entity_names" bson:"entity_names"` + DatetimeSettings struct { + DatePattern string `json:"date_pattern" bson:"date_pattern"` + ShortDatePattern string `json:"short_date_pattern" bson:"short_date_pattern"` + ShortTimePattern string `json:"short_time_pattern" bson:"short_time_pattern"` + DateFormant string `json:"date_formant" bson:"date_formant"` + TimeFormat string `json:"time_format" bson:"time_format"` + Timezone string `json:"timezone" bson:"timezone"` + TimezoneOffset string `json:"timezone_offset" bson:"timezone_offset"` + } `json:"datetime_settings" bson:"datetime_settings"` + } `json:"_embedded" bson:"_embedded"` +} + +type LeadRights struct { + View string `json:"view"` + Edit string `json:"edit"` + Add string `json:"add"` + Delete string `json:"delete"` + Export string `json:"export"` +} + +type ContactRights struct { + View string `json:"view"` + Edit string `json:"edit"` + Add string `json:"add"` + Delete string `json:"delete"` + Export string `json:"export"` +} + +type CompanyRights struct { + View string `json:"view"` + Edit string `json:"edit"` + Add string `json:"add"` + Delete string `json:"delete"` + Export string `json:"export"` +} + +type TaskRights struct { + Edit string `json:"edit"` + Delete string `json:"delete"` +} + +type StatusRight struct { + EntityType string `json:"entity_type"` + PipelineID int `json:"pipeline_id"` + StatusID int `json:"status_id"` + Rights LeadRights `json:"rights"` +} + +type OneUserInfo struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Lang string `json:"lang"` + Role *string `json:"role,omitempty"` + UUID *string `json:"uuid,omitempty"` + Rights Rights `json:"rights"` + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + } `json:"_links"` +} diff --git a/internal/proto/socialauth/models.pb.go b/internal/proto/socialauth/models.pb.go new file mode 100644 index 0000000..9a7236f --- /dev/null +++ b/internal/proto/socialauth/models.pb.go @@ -0,0 +1,165 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.31.0 +// protoc (unknown) +// source: social_auth/v1/models.proto + +package socialauth + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Message struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + State string `protobuf:"bytes,1,opt,name=State,proto3" json:"State,omitempty"` + ReturnURL string `protobuf:"bytes,2,opt,name=ReturnURL,proto3" json:"ReturnURL,omitempty"` + AccessToken *string `protobuf:"bytes,3,opt,name=AccessToken,proto3,oneof" json:"AccessToken,omitempty"` +} + +func (x *Message) Reset() { + *x = Message{} + if protoimpl.UnsafeEnabled { + mi := &file_social_auth_v1_models_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Message) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Message) ProtoMessage() {} + +func (x *Message) ProtoReflect() protoreflect.Message { + mi := &file_social_auth_v1_models_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Message.ProtoReflect.Descriptor instead. +func (*Message) Descriptor() ([]byte, []int) { + return file_social_auth_v1_models_proto_rawDescGZIP(), []int{0} +} + +func (x *Message) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Message) GetReturnURL() string { + if x != nil { + return x.ReturnURL + } + return "" +} + +func (x *Message) GetAccessToken() string { + if x != nil && x.AccessToken != nil { + return *x.AccessToken + } + return "" +} + +var File_social_auth_v1_models_proto protoreflect.FileDescriptor + +var file_social_auth_v1_models_proto_rawDesc = []byte{ + 0x0a, 0x1b, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x2f, 0x76, 0x31, + 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x73, + 0x6f, 0x63, 0x69, 0x61, 0x6c, 0x61, 0x75, 0x74, 0x68, 0x22, 0x74, 0x0a, 0x07, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x52, 0x65, + 0x74, 0x75, 0x72, 0x6e, 0x55, 0x52, 0x4c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x52, + 0x65, 0x74, 0x75, 0x72, 0x6e, 0x55, 0x52, 0x4c, 0x12, 0x25, 0x0a, 0x0b, 0x41, 0x63, 0x63, 0x65, + 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, + 0x0b, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x88, 0x01, 0x01, 0x42, + 0x0e, 0x0a, 0x0c, 0x5f, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x42, + 0x0e, 0x5a, 0x0c, 0x2e, 0x2f, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x6c, 0x61, 0x75, 0x74, 0x68, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_social_auth_v1_models_proto_rawDescOnce sync.Once + file_social_auth_v1_models_proto_rawDescData = file_social_auth_v1_models_proto_rawDesc +) + +func file_social_auth_v1_models_proto_rawDescGZIP() []byte { + file_social_auth_v1_models_proto_rawDescOnce.Do(func() { + file_social_auth_v1_models_proto_rawDescData = protoimpl.X.CompressGZIP(file_social_auth_v1_models_proto_rawDescData) + }) + return file_social_auth_v1_models_proto_rawDescData +} + +var file_social_auth_v1_models_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_social_auth_v1_models_proto_goTypes = []interface{}{ + (*Message)(nil), // 0: socialauth.Message +} +var file_social_auth_v1_models_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_social_auth_v1_models_proto_init() } +func file_social_auth_v1_models_proto_init() { + if File_social_auth_v1_models_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_social_auth_v1_models_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Message); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_social_auth_v1_models_proto_msgTypes[0].OneofWrappers = []interface{}{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_social_auth_v1_models_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_social_auth_v1_models_proto_goTypes, + DependencyIndexes: file_social_auth_v1_models_proto_depIdxs, + MessageInfos: file_social_auth_v1_models_proto_msgTypes, + }.Build() + File_social_auth_v1_models_proto = out.File + file_social_auth_v1_models_proto_rawDesc = nil + file_social_auth_v1_models_proto_goTypes = nil + file_social_auth_v1_models_proto_depIdxs = nil +} diff --git a/internal/repository/redis_repo.go b/internal/repository/redis_repo.go new file mode 100644 index 0000000..c2223cf --- /dev/null +++ b/internal/repository/redis_repo.go @@ -0,0 +1,139 @@ +package repository + +import ( + "amocrm/internal/models" + "context" + "encoding/json" + "fmt" + "github.com/go-redis/redis/v8" + "go.uber.org/zap" + "strconv" + "sync" +) + +type Repository struct { + redisClient *redis.Client + logger *zap.Logger +} + +type Deps struct { + RedisClient *redis.Client + Logger *zap.Logger +} + +func NewRepository(deps Deps) *Repository { + return &Repository{ + redisClient: deps.RedisClient, + logger: deps.Logger, + } +} + +func (r *Repository) CachingDealToRedis(ctx context.Context, deps models.SaveDeal) error { + key := "deal:" + strconv.FormatInt(deps.AnswerID, 10) + ":" + strconv.Itoa(int(deps.DealID)) + valueJson, err := json.Marshal(deps) + if err != nil { + return err + } + + err = r.redisClient.Set(ctx, key, valueJson, 0).Err() + if err != nil { + return err + } + + return nil +} + +func (r *Repository) CachingLeadFieldsToRedis(ctx context.Context, answerID int64, leadFields []models.FieldsValues) error { + key := strconv.FormatInt(answerID, 10) + leadFieldsJson, err := json.Marshal(leadFields) + if err != nil { + return err + } + + err = r.redisClient.Set(ctx, key, leadFieldsJson, 0).Err() + if err != nil { + return err + } + + return nil +} + +func (r *Repository) FetchingDeals(ctx context.Context) (map[string][]models.MappingDealsData, map[int32]models.ForRestoringData, error) { + keys, err := r.redisClient.Keys(ctx, "deal:*").Result() + if err != nil { + r.logger.Error("error fetching keys from Redis", zap.Error(err)) + return nil, nil, err + } + + var ( + mu sync.Mutex + dealsDataForUpdate = make(map[string][]models.MappingDealsData) + forRestoringMap = make(map[int32]models.ForRestoringData) + wg sync.WaitGroup + ) + + wg.Add(len(keys)) + + for _, key := range keys { + go func(key string) { + defer wg.Done() + + saveDealJSON, err := r.redisClient.GetDel(ctx, key).Result() + if err != nil { + r.logger.Error("error getting saveDeal JSON from Redis", zap.Error(err)) + return + } + + var saveDeal models.SaveDeal + err = json.Unmarshal([]byte(saveDealJSON), &saveDeal) + if err != nil { + r.logger.Error("error unmarshal saveDeal JSON", zap.Error(err)) + return + } + answerIDStr := strconv.FormatInt(saveDeal.AnswerID, 10) + + leadFieldsJSON, err := r.redisClient.GetDel(ctx, answerIDStr).Result() + if err != nil { + r.logger.Error("error getting leadFields JSON from Redis", zap.Error(err)) + return + } + + var leadFields []models.FieldsValues + err = json.Unmarshal([]byte(leadFieldsJSON), &leadFields) + if err != nil { + r.logger.Error("error unmarshal leadFields JSON", zap.Error(err)) + return + } + + fmt.Println("CUSTOM ENCODER", leadFields) + + mu.Lock() + defer mu.Unlock() + + dealsDataForUpdate[saveDeal.AccessToken] = append(dealsDataForUpdate[saveDeal.AccessToken], models.MappingDealsData{ + AnswerID: saveDeal.AnswerID, + DealID: saveDeal.DealID, + LeadFields: leadFields, + SubDomain: saveDeal.SubDomain, + }) + + forRestoringMap[saveDeal.DealID] = models.ForRestoringData{ + SaveDeal: saveDeal, + LeadFields: leadFields, + } + }(key) + } + + wg.Wait() + + return dealsDataForUpdate, forRestoringMap, nil +} + +func (r *Repository) Close(_ context.Context) error { + err := r.redisClient.Close() + if err != nil { + return err + } + + return nil +} diff --git a/internal/server/http/http.go b/internal/server/http/http.go new file mode 100644 index 0000000..74a672a --- /dev/null +++ b/internal/server/http/http.go @@ -0,0 +1,66 @@ +package http + +import ( + "context" + "fmt" + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/middleware" +) + +type ServerConfig struct { + Controllers []Controller +} + +type Server struct { + Controllers []Controller + app *fiber.App +} + +func NewServer(config ServerConfig) *Server { + app := fiber.New() + app.Use("/amocrm", middleware.JWTAuth()) + app.Use("/webhook", func(c *fiber.Ctx) error { + return c.Next() + }) + + s := &Server{ + Controllers: config.Controllers, + app: app, + } + + s.registerRoutes() + + return s +} + +func (s *Server) Start(addr string) error { + if err := s.app.Listen(addr); err != nil { + return err + } + return nil +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.app.Shutdown() +} + +func (s *Server) registerRoutes() { + for _, c := range s.Controllers { + router := s.app.Group(c.Name()) + c.Register(router) + } +} + +type Controller interface { + Register(router fiber.Router) + Name() string +} + +func (s *Server) ListRoutes() { + fmt.Println("Registered routes:") + for _, stack := range s.app.Stack() { + for _, route := range stack { + fmt.Printf("%s %s\n", route.Method, route.Path) + } + } +} diff --git a/internal/service/fields.go b/internal/service/fields.go new file mode 100644 index 0000000..3dfa135 --- /dev/null +++ b/internal/service/fields.go @@ -0,0 +1,38 @@ +package service + +import ( + "amocrm/internal/models" + "amocrm/internal/tools" + "context" + "database/sql" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (s *Service) GetFieldsWithPagination(ctx context.Context, req *model.PaginationReq, accountID string) (*model.UserListFieldsResp, error) { + response, err := s.repository.AmoRepo.GetFieldsWithPagination(ctx, req, accountID) + if err != nil { + if err == sql.ErrNoRows { + return nil, pj_errors.ErrNotFound + } + s.logger.Error("error getting fields with pagination", zap.Error(err)) + return nil, err + } + return tools.ValidateUtmFields(response), nil +} + +func (s *Service) UpdateListCustom(ctx context.Context, accountID string) error { + message := models.KafkaMessage{ + AccountID: accountID, + Type: models.FieldsUpdate, + } + + err := s.producer.ToKafkaUpdate(ctx, message) + if err != nil { + s.logger.Error("failed to send message to kafka on service update fields", zap.Error(err)) + return err + } + + return nil +} diff --git a/internal/service/initial.go b/internal/service/initial.go new file mode 100644 index 0000000..2cf13b8 --- /dev/null +++ b/internal/service/initial.go @@ -0,0 +1,35 @@ +package service + +import ( + "amocrm/internal/brokers" + "amocrm/pkg/amoClient" + pena_social_auth "amocrm/pkg/pena-social-auth" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" +) + +type Deps struct { + Repository *dal.AmoDal + Logger *zap.Logger + SocialAuthClient *pena_social_auth.Client + AmoClient *amoClient.Amo + Producer *brokers.Producer +} + +type Service struct { + repository *dal.AmoDal + logger *zap.Logger + socialAuthClient *pena_social_auth.Client + amoClient *amoClient.Amo + producer *brokers.Producer +} + +func NewService(deps Deps) *Service { + return &Service{ + repository: deps.Repository, + logger: deps.Logger, + socialAuthClient: deps.SocialAuthClient, + amoClient: deps.AmoClient, + producer: deps.Producer, + } +} diff --git a/internal/service/pipelines.go b/internal/service/pipelines.go new file mode 100644 index 0000000..5a58a5a --- /dev/null +++ b/internal/service/pipelines.go @@ -0,0 +1,37 @@ +package service + +import ( + "amocrm/internal/models" + "context" + "database/sql" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (s *Service) UpdateListPipelines(ctx context.Context, accountID string) error { + message := models.KafkaMessage{ + AccountID: accountID, + Type: models.PipelinesUpdate, + } + + err := s.producer.ToKafkaUpdate(ctx, message) + if err != nil { + s.logger.Error("failed to send message to kafka on service update pipelines", zap.Error(err)) + return err + } + + return nil +} + +func (s *Service) GetPipelinesWithPagination(ctx context.Context, req *model.PaginationReq, accountID string) (*model.UserListPipelinesResp, error) { + response, err := s.repository.AmoRepo.GetPipelinesWithPagination(ctx, req, accountID) + if err != nil { + if err == sql.ErrNoRows { + return nil, pj_errors.ErrNotFound + } + s.logger.Error("error getting pipelines with pagination", zap.Error(err)) + return nil, err + } + return response, nil +} diff --git a/internal/service/rules.go b/internal/service/rules.go new file mode 100644 index 0000000..d2c2e57 --- /dev/null +++ b/internal/service/rules.go @@ -0,0 +1,88 @@ +package service + +import ( + "amocrm/internal/models" + "context" + "database/sql" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (s *Service) ChangeQuizSettings(ctx context.Context, request *model.RulesReq, accountID string, quizID int) error { + err := s.repository.AmoRepo.ChangeQuizSettings(ctx, request, accountID, quizID) + if err != nil { + if err == sql.ErrNoRows { + return pj_errors.ErrNotFound + } + s.logger.Error("error change quiz settings", zap.Error(err)) + return err + } + + messageForUTM := models.KafkaMessage{ + AccountID: accountID, + Type: models.RuleCheck, + Rule: models.KafkaRule{ + QuizID: int32(quizID), + PerformerID: request.PerformerID, + PipelineID: request.PipelineID, + StepID: request.StepID, + Fieldsrule: request.Fieldsrule, + }, + } + + err = s.producer.ToKafkaUpdate(ctx, messageForUTM) + if err != nil { + s.logger.Error("error sending message to kafka for check rules", zap.Error(err)) + if err != nil { + return err + } + } + + return nil +} + +func (s *Service) SetQuizSettings(ctx context.Context, request *model.RulesReq, accountID string, quizID int) error { + err := s.repository.AmoRepo.SetQuizSettings(ctx, request, accountID, quizID) + if err != nil { + if err == sql.ErrNoRows { + return pj_errors.ErrNotFound + } + s.logger.Error("error setting quiz settings", zap.Error(err)) + return err + } + + messageForUTM := models.KafkaMessage{ + AccountID: accountID, + Type: models.RuleCheck, + Rule: models.KafkaRule{ + QuizID: int32(quizID), + PerformerID: request.PerformerID, + PipelineID: request.PipelineID, + StepID: request.StepID, + Fieldsrule: request.Fieldsrule, + }, + } + + err = s.producer.ToKafkaUpdate(ctx, messageForUTM) + if err != nil { + s.logger.Error("error sending message to kafka for check rules", zap.Error(err)) + if err != nil { + return err + } + } + + return nil +} + +func (s *Service) GettingQuizRules(ctx context.Context, quizID int) (*model.Rule, error) { + rule, err := s.repository.AmoRepo.GettingQuizRules(ctx, quizID) + if err != nil { + if err == sql.ErrNoRows { + return nil, pj_errors.ErrNotFound + } + s.logger.Error("error getting quiz settings", zap.Error(err)) + return nil, err + } + return rule, nil +} diff --git a/internal/service/steps.go b/internal/service/steps.go new file mode 100644 index 0000000..bd8c3d0 --- /dev/null +++ b/internal/service/steps.go @@ -0,0 +1,37 @@ +package service + +import ( + "amocrm/internal/models" + "context" + "database/sql" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (s *Service) GetStepsWithPagination(ctx context.Context, req *model.PaginationReq, accountID string, pipelineID int) (*model.UserListStepsResp, error) { + response, err := s.repository.AmoRepo.GetStepsWithPagination(ctx, req, accountID, int32(pipelineID)) + if err != nil { + if err == sql.ErrNoRows { + return nil, pj_errors.ErrNotFound + } + s.logger.Error("error getting steps with pagination", zap.Error(err)) + return nil, err + } + return response, nil +} + +func (s *Service) UpdateListSteps(ctx context.Context, accountID string) error { + message := models.KafkaMessage{ + AccountID: accountID, + Type: models.PipelinesUpdate, + } + + err := s.producer.ToKafkaUpdate(ctx, message) + if err != nil { + s.logger.Error("failed to send message to kafka on service update steps", zap.Error(err)) + return err + } + + return nil +} diff --git a/internal/service/tags.go b/internal/service/tags.go new file mode 100644 index 0000000..532cedd --- /dev/null +++ b/internal/service/tags.go @@ -0,0 +1,37 @@ +package service + +import ( + "amocrm/internal/models" + "context" + "database/sql" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (s *Service) GetTagsWithPagination(ctx context.Context, req *model.PaginationReq, accountID string) (*model.UserListTagsResp, error) { + response, err := s.repository.AmoRepo.GetTagsWithPagination(ctx, req, accountID) + if err != nil { + if err == sql.ErrNoRows { + return nil, pj_errors.ErrNotFound + } + s.logger.Error("error getting tags with pagination", zap.Error(err)) + return nil, err + } + return response, nil +} + +func (s *Service) UpdateListTags(ctx context.Context, accountID string) error { + message := models.KafkaMessage{ + AccountID: accountID, + Type: models.TagsUpdate, + } + + err := s.producer.ToKafkaUpdate(ctx, message) + if err != nil { + s.logger.Error("failed to send message to kafka on service update tags", zap.Error(err)) + return err + } + + return nil +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..9df28da --- /dev/null +++ b/internal/service/user.go @@ -0,0 +1,70 @@ +package service + +import ( + "amocrm/internal/models" + "context" + "database/sql" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +func (s *Service) UpdateListUsers(ctx context.Context, accountID string) error { + message := models.KafkaMessage{ + AccountID: accountID, + Type: models.UsersUpdate, + } + + err := s.producer.ToKafkaUpdate(ctx, message) + if err != nil { + s.logger.Error("failed to send message to kafka on service update users", zap.Error(err)) + return err + } + + return nil +} + +func (s *Service) GettingUserWithPagination(ctx context.Context, req *model.PaginationReq, accountID string) (*model.UserListResp, error) { + response, err := s.repository.AmoRepo.GettingUserWithPagination(ctx, req, accountID) + if err != nil { + s.logger.Error("error getting users with pagination", zap.Error(err)) + return nil, err + } + return response, nil +} + +func (s *Service) SoftDeleteAccount(ctx context.Context, accountID string) error { + err := s.repository.AmoRepo.SoftDeleteAccount(ctx, accountID) + if err != nil { + s.logger.Error("error soft delete current account in softDeleteAccount service", zap.Error(err)) + return err + } + return nil +} + +func (s *Service) GetCurrentAccount(ctx context.Context, accountID string) (*model.AmoAccount, error) { + user, err := s.repository.AmoRepo.GetCurrentAccount(ctx, accountID) + if err != nil { + if err == sql.ErrNoRows { + return nil, pj_errors.ErrNotFound + } + s.logger.Error("error getting current account in getCurrentAccount service", zap.Error(err)) + return nil, err + } + + return user, nil +} + +func (s *Service) ConnectAccount(ctx context.Context, accountID string) (*model.ConnectAccountResp, error) { + link, err := s.socialAuthClient.GenerateAmocrmAuthURL(accountID) + if err != nil { + s.logger.Error("error sending request to pena social auth service:", zap.Error(err)) + return nil, err + } + + response := model.ConnectAccountResp{ + Link: link, + } + + return &response, nil +} diff --git a/internal/service/utm.go b/internal/service/utm.go new file mode 100644 index 0000000..491ba76 --- /dev/null +++ b/internal/service/utm.go @@ -0,0 +1,34 @@ +package service + +//func (s *Service) DeletingUserUtm(ctx context.Context, request *model.ListDeleteUTMIDsReq) error { +// err := s.repository.AmoRepo.DeletingUserUtm(ctx, request) +// if err != nil { +// s.logger.Error("error deleting user utm", zap.Error(err)) +// return err +// } +// return nil +//} +// +//func (s *Service) SavingUserUtm(ctx context.Context, request *model.SaveUserListUTMReq, accountID string, quizID int) (*model.ListSavedIDUTMResp, error) { +// var utms []model.UTM +// for _, utm := range request.Utms { +// utm.Quizid = int32(quizID) +// utms = append(utms, utm) +// } +// +// response, err := s.repository.AmoRepo.SavingUserUtm(ctx, utms, accountID) +// if err != nil { +// s.logger.Error("error saving user utm", zap.Error(err)) +// return nil, err +// } +// return response, nil +//} +// +//func (s *Service) GettingUserUtm(ctx context.Context, request *model.PaginationReq, accountID string, quizID int) (*model.GetListUserUTMResp, error) { +// response, err := s.repository.AmoRepo.GettingUserUtm(ctx, request, accountID, quizID) +// if err != nil { +// s.logger.Error("error getting user utm with pagination", zap.Error(err)) +// return nil, err +// } +// return response, nil +//} diff --git a/internal/service/webhook.go b/internal/service/webhook.go new file mode 100644 index 0000000..ddc44e0 --- /dev/null +++ b/internal/service/webhook.go @@ -0,0 +1,65 @@ +package service + +import ( + "amocrm/internal/models" + "context" + "errors" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" +) + +type ParamsWebhookCreate struct { + Code string // Authorization 20 минут + Referer string // адрес аккаунта пользователя + AccountID string // строка которая передавалась в соц аус сервисе + FromWidget string + Platform string // ru/global 1/2 +} + +func (s *Service) WebhookCreate(ctx context.Context, req ParamsWebhookCreate) error { + _, err := s.GetCurrentAccount(ctx, req.AccountID) + if err != nil && !errors.Is(err, pj_errors.ErrNotFound) { + s.logger.Error("error checking current account in amo in webhook create", zap.Error(err)) + return err + } + + if errors.Is(err, pj_errors.ErrNotFound) { + message := models.KafkaMessage{ + AccountID: req.AccountID, + AuthCode: req.Code, + RefererURL: req.Referer, + Type: models.UserCreate, + } + + err = s.producer.ToKafkaUpdate(ctx, message) + if err != nil { + s.logger.Error("failed to send message to kafka on service webhook create", zap.Error(err)) + return err + } + return nil + } + + message := models.KafkaMessage{ + AccountID: req.AccountID, + AuthCode: req.Code, + RefererURL: req.Referer, + Type: models.UserReLogin, + } + + err = s.producer.ToKafkaUpdate(ctx, message) + if err != nil { + s.logger.Error("failed to send message to kafka on service webhook create, user re-login", zap.Error(err)) + return err + } + + return nil +} + +func (s *Service) WebhookDelete(ctx context.Context, amoID int) error { + err := s.repository.AmoRepo.WebhookDelete(ctx, amoID) + if err != nil { + s.logger.Error("error canceled amo integration", zap.Error(err)) + return err + } + return nil +} diff --git a/internal/tools/construct.go b/internal/tools/construct.go new file mode 100644 index 0000000..4c7b026 --- /dev/null +++ b/internal/tools/construct.go @@ -0,0 +1,174 @@ +package tools + +import ( + "amocrm/internal/models" + "fmt" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "strings" + "unicode/utf8" +) + +func ToPipeline(amoPipelines []models.Pipeline) []model.Pipeline { + var pipelines []model.Pipeline + for _, p := range amoPipelines { + pipeline := model.Pipeline{ + Amoid: int32(p.ID), + Name: p.Name, + Isarchive: p.IsArchive, + AccountID: int32(p.AccountID), + } + pipelines = append(pipelines, pipeline) + } + return pipelines +} + +func ToStep(amoStatuses []models.Statuses) []model.Step { + var steps []model.Step + for _, s := range amoStatuses { + step := model.Step{ + Amoid: int32(s.ID), + Name: s.Name, + Pipelineid: int32(s.PipelineID), + Color: s.Color, + Accountid: int32(s.AccountID), + } + steps = append(steps, step) + } + return steps +} + +func ToTag(amoTag []models.Tag, entity model.EntityType) []model.Tag { + var tags []model.Tag + for _, t := range amoTag { + tag := model.Tag{ + Amoid: int32(t.ID), + Entity: entity, + Name: t.Name, + Color: t.Color, + } + tags = append(tags, tag) + } + return tags +} + +func ToField(amoField []models.CustomField, entity model.EntityType) []model.Field { + var fields []model.Field + for _, f := range amoField { + field := model.Field{ + Amoid: int32(f.ID), + Code: f.Code, + Name: f.Name, + Entity: entity, + Type: model.FieldType(f.Type), + } + fields = append(fields, field) + } + + return fields +} + +func isEmoji(r rune) bool { + // https://symbl.cc/ru/unicode/blocks/emoticons/ + + return (r >= 0x1F600 && r <= 0x1F64F) || // эмотикоины + (r >= 0x1F650 && r <= 0x1F67F) || // орнаментные символы + (r >= 0x1F680 && r <= 0x1F6FF) || // Транспортные и картографические символы + (r >= 0x1F700 && r <= 0x1F77F) || // Алхимические символы + (r >= 0x1F780 && r <= 0x1F7FF) || // Расширенные геометрические фигуры + (r >= 0x1F800 && r <= 0x1F8FF) || // Дополнительные стрелки — С + (r >= 0x1F900 && r <= 0x1F9FF) || // Дополнительные символы и пиктограммы + (r >= 0x1FA00 && r <= 0x1FA6F) || // Шахматные символы + (r >= 0x1FA70 && r <= 0x1FAFF) || // Расширенные символы и пиктограммы — A + (r >= 0x1FB00 && r <= 0x1FBFF) || // Символы наследия вычислительной техники + (r >= 0x20000 && r <= 0x2A6DF) || // Унифицированные идеограммы ККЯ. Расширение B + (r >= 0x2A700 && r <= 0x2B73F) || // Унифицированные идеограммы ККЯ. Расширение C + (r >= 0x2B740 && r <= 0x2B81F) || // Унифицированные идеограммы ККЯ. Расширение D + (r >= 0x2B820 && r <= 0x2CEAF) || // Унифицированные идеограммы ККЯ. Расширение E + (r >= 0x2CEB0 && r <= 0x2EBEF) || // Унифицированные идеограммы ККЯ. Расширение F + (r >= 0x2EBF0 && r <= 0x2EE5F) || // CJK Unified Ideographs Extension I + (r >= 0x2F800 && r <= 0x2FA1F) || // Дополнение к совместимым идеограммам ККЯ + (r >= 0x30000 && r <= 0x3134F) || // Унифицированные идеограммы ККЯ. Расширение G + (r >= 0x31350 && r <= 0x323AF) || // Унифицированные идеограммы ККЯ. Расширение H + (r >= 0xE0000 && r <= 0xE007F) || // Теги + (r >= 0xE0100 && r <= 0xE01EF) // Дополнение к селекторам вариантов начертания +} + +func EmojiUnicode(text string) string { + var result strings.Builder + for len(text) > 0 { + r, size := utf8.DecodeRuneInString(text) + if size == -1 { + result.WriteString(text[:1]) + text = text[1:] + } else { + if isEmoji(r) { + result.WriteString(fmt.Sprintf(`"0x%x"`, r)) + } else { + result.WriteRune(r) + } + text = text[size:] + } + } + return result.String() +} + +func AddContactFields(contactFields []models.FieldsValues, fieldValue string, fieldType model.ContactQuizConfig, fieldMap map[string]int) []models.FieldsValues { + if fieldValue != "" && fieldMap[string(fieldType)] != 0 { + values := make([]models.ValueInterface, 0) + values = append(values, models.Values{Value: fieldValue}) + + contactFields = append(contactFields, models.FieldsValues{ + FieldID: fieldMap[string(fieldType)], + Values: values, + }) + } + return contactFields +} + +func ConstructUTMFields(utmMap model.UTMSavingMap, currentFields []model.Field) []models.FieldsValues { + var fields []models.FieldsValues + for _, field := range currentFields { + if data, ok := utmMap[field.Name]; ok { + val := []models.ValueInterface{ + models.Values{ + Value: data, + }, + } + f := models.FieldsValues{ + FieldID: int(field.Amoid), + Values: val, + } + fields = append(fields, f) + continue + } + } + + return fields +} + +func ConstructAmoTags(currentTags []model.Tag, ruleTags model.TagsToAdd) []models.Tag { + var tagsToAmo []models.Tag + ruleTagMap := make(map[int64]struct{}) + + mapConstruct := func(idsArray []int64) { + for _, id := range idsArray { + ruleTagMap[id] = struct{}{} + } + } + + mapConstruct(ruleTags.Lead) + mapConstruct(ruleTags.Contact) + mapConstruct(ruleTags.Company) + mapConstruct(ruleTags.Customer) + + for _, tag := range currentTags { + if _, ok := ruleTagMap[int64(tag.Amoid)]; ok { + tagsToAmo = append(tagsToAmo, models.Tag{ + ID: int(tag.Amoid), + Name: tag.Name, + //Color: tag.Color, + }) + } + } + return tagsToAmo +} diff --git a/internal/tools/for_rules.go b/internal/tools/for_rules.go new file mode 100644 index 0000000..a6af7c8 --- /dev/null +++ b/internal/tools/for_rules.go @@ -0,0 +1,100 @@ +package tools + +import ( + "amocrm/internal/models" + "fmt" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "strings" +) + +type ToUpdate struct { + FieldID int + Entity model.EntityType +} + +func ToCreatedUpdateQuestionRules(questionsTypeMap map[model.EntityType][]model.Question, currentFields []model.Field) (map[model.EntityType][]models.AddLeadsFields, map[int]ToUpdate) { + toUpdate := make(map[int]ToUpdate) // на обновление ключ id вопроса значение id кастомного поля для тех у кого имя совпадает + toCreated := make(map[model.EntityType][]models.AddLeadsFields) + for entity, questions := range questionsTypeMap { + for _, question := range questions { + // если заголоввок пустой у вопроса делаем ему заголовок чтоб в амо легли филды нормально + title := strings.ToLower(strings.ReplaceAll(question.Title, " ", "")) + if title == "" { + question.Title = fmt.Sprintf("Вопрос №%d", question.Page) + } + title = strings.ToLower(strings.ReplaceAll(question.Title, " ", "")) + matched := false + for _, field := range currentFields { + fieldName := strings.ToLower(strings.ReplaceAll(field.Name, " ", "")) + if title == fieldName && entity == field.Entity { + toUpdate[int(question.Id)] = ToUpdate{ + FieldID: int(field.Amoid), + Entity: entity, + } + matched = true + break + } + } + + if !matched { + //Type: model.TypeMapping[question.Type] + fieldType := model.TypeAmoTextarea + if question.Type == model.TypeFile { + fieldType = model.TypeAmoFile + } + toCreated[entity] = append(toCreated[entity], models.AddLeadsFields{Type: fieldType, Name: question.Title}) + } + } + } + + return toCreated, toUpdate +} + +func ToQuestionIDs(rule map[int]int) []int32 { + var ids []int32 + for queID, fieldID := range rule { + if fieldID != 0 { + continue + } + ids = append(ids, int32(queID)) + } + return ids +} + +func ForContactRules(quizConfig model.QuizContact, currentFields []model.Field) ([]models.AddLeadsFields, map[string]int) { + var contactFieldsArr []model.ContactQuizConfig + contactFieldTypes := map[model.ContactQuizConfig]bool{ + model.TypeContactName: quizConfig.FormContact.Fields.Name.Used, + model.TypeContactEmail: quizConfig.FormContact.Fields.Email.Used, + model.TypeContactPhone: quizConfig.FormContact.Fields.Phone.Used, + model.TypeContactText: quizConfig.FormContact.Fields.Text.Used, + model.TypeContactAddress: quizConfig.FormContact.Fields.Address.Used, + } + + for fieldType, used := range contactFieldTypes { + if used { + contactFieldsArr = append(contactFieldsArr, fieldType) + } + } + + forAdding := make(map[string]int) + var toCreated []models.AddLeadsFields + for _, contactField := range contactFieldsArr { + matched := false + for _, field := range currentFields { + if field.Name == string(contactField) && field.Entity == model.ContactsType { + matched = true + forAdding[string(contactField)] = int(field.Amoid) + break + } + } + + if !matched { + //Type: model.TypeMapping[question.Type] + toCreated = append(toCreated, models.AddLeadsFields{Type: model.TypeAmoText, Name: string(contactField)}) + forAdding[string(contactField)] = 0 + } + } + + return toCreated, forAdding +} diff --git a/internal/tools/groups.go b/internal/tools/groups.go new file mode 100644 index 0000000..c3a7640 --- /dev/null +++ b/internal/tools/groups.go @@ -0,0 +1,29 @@ +package tools + +import ( + "amocrm/internal/models" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" +) + +func ConvertUserGroups(groups *models.AmocrmUserInformation) []model.UserGroups { + var userGroups []model.UserGroups + for _, group := range groups.Embedded.UsersGroups { + userGroups = append(userGroups, model.UserGroups{ + ID: group.ID, + Name: group.Name, + UUID: group.UUID, + }) + } + return userGroups +} + +//func ConvertGroups(groups models.Users) []model.UserGroups { +// var userGroups []model.UserGroups +// for _, group := range groups.Embedded.Groups { +// userGroups = append(userGroups, model.UserGroups{ +// ID: group.ID, +// Name: group.Name, +// }) +// } +// return userGroups +//} diff --git a/internal/tools/proto.go b/internal/tools/proto.go new file mode 100644 index 0000000..714c2c7 --- /dev/null +++ b/internal/tools/proto.go @@ -0,0 +1,25 @@ +package tools + +import ( + "amocrm/internal/proto/socialauth" + "google.golang.org/protobuf/proto" + "fmt" +) + +func DeserializeProtobufMessage(protobufMessage string) (string, string, error) { + msg := socialauth.Message{} + + err := proto.Unmarshal([]byte(protobufMessage), &msg) + if err != nil { + return "", "", err + } + + fmt.Println("PROTOOTOT", msg.State, *msg.AccessToken) + + var accountID string + if msg.AccessToken != nil { + accountID = *msg.AccessToken + } + + return accountID, msg.ReturnURL, nil +} diff --git a/internal/tools/validate.go b/internal/tools/validate.go new file mode 100644 index 0000000..b40c958 --- /dev/null +++ b/internal/tools/validate.go @@ -0,0 +1,42 @@ +package tools + +import ( + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" +) + +func ValidateUtmFields(response *model.UserListFieldsResp) *model.UserListFieldsResp { + checkUTM := map[string]struct{}{ + "utm_content": {}, + "utm_medium": {}, + "utm_campaign": {}, + "utm_source": {}, + "utm_term": {}, + "utm_referrer": {}, + "roistat": {}, + "referrer": {}, + "openstat_service": {}, + "openstat_campaign": {}, + "openstat_ad": {}, + "openstat_source": {}, + "from": {}, + "gclientid": {}, + "_ym_uid": {}, + "_ym_counter": {}, + "gclid": {}, + "yclid": {}, + "fbclid": {}, + } + + data := &model.UserListFieldsResp{ + Count: response.Count, + Items: []model.Field{}, + } + + for _, r := range response.Items { + if _, ok := checkUTM[r.Name]; !ok { + data.Items = append(data.Items, r) + } + } + + return data +} diff --git a/internal/tools/verify.go b/internal/tools/verify.go new file mode 100644 index 0000000..692fe87 --- /dev/null +++ b/internal/tools/verify.go @@ -0,0 +1,40 @@ +package tools + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +type Verify struct { + integrationSecret string + integrationID string +} + +func NewVerify(integrationSecret, integrationID string) *Verify { + return &Verify{ + integrationSecret: integrationSecret, + integrationID: integrationID, + } +} + +func (v *Verify) VerifySignature(clientUUID, signature string, amoID int) bool { + expected := v.getSignature(clientUUID, amoID) + return hmac.Equal([]byte(signature), []byte(expected)) +} + +func (v *Verify) getSignature(clientUUID string, amoID int) string { + message := fmt.Sprintf("%s|%d", clientUUID, amoID) + h := hmac.New(sha256.New, []byte(v.integrationSecret)) + h.Write([]byte(message)) + return hex.EncodeToString(h.Sum(nil)) +} + +func (v *Verify) CheckIntegrationID(clientUUID string) bool { + if v.integrationID != clientUUID { + return false + } + + return true +} diff --git a/internal/workers/data_updater/data_updater.go b/internal/workers/data_updater/data_updater.go new file mode 100644 index 0000000..93ae13a --- /dev/null +++ b/internal/workers/data_updater/data_updater.go @@ -0,0 +1,59 @@ +package data_updater + +import ( + "amocrm/internal/brokers" + "amocrm/internal/models" + "context" + "go.uber.org/zap" + "time" +) + +type Deps struct { + Logger *zap.Logger + Producer *brokers.Producer +} + +type DataUpdater struct { + logger *zap.Logger + producer *brokers.Producer +} + +func NewDataUpdaterWC(deps Deps) *DataUpdater { + return &DataUpdater{ + logger: deps.Logger, + producer: deps.Producer, + } +} + +func (wc *DataUpdater) Start(ctx context.Context) { + nextStart := calculateTime() + ticker := time.NewTicker(time.Nanosecond * time.Duration(nextStart)) + //ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + wc.processTasks(ctx) + nextStart = calculateTime() + ticker.Reset(time.Nanosecond * time.Duration(nextStart)) + case <-ctx.Done(): + return + } + } +} + +func (wc *DataUpdater) processTasks(ctx context.Context) { + err := wc.producer.ToKafkaUpdate(ctx, models.KafkaMessage{ + Type: models.AllDataUpdate, + AccountID: "", + }) + if err != nil { + wc.logger.Error("error send task in kafka ro update all data", zap.Error(err)) + return + } +} + +func (wc *DataUpdater) Stop(_ context.Context) error { + return nil +} diff --git a/internal/workers/data_updater/timer.go b/internal/workers/data_updater/timer.go new file mode 100644 index 0000000..ae4a113 --- /dev/null +++ b/internal/workers/data_updater/timer.go @@ -0,0 +1,19 @@ +package data_updater + +import ( + "time" +) + +func calculateTime() int64 { + now := time.Now() + + targetTime := time.Date(now.Year(), now.Month(), now.Day(), 4, 0, 0, 0, now.Location()) + if now.After(targetTime) { + targetTime = targetTime.AddDate(0, 0, 1) + } + + toTarget := targetTime.Sub(now) + sec := toTarget.Nanoseconds() + + return sec +} diff --git a/internal/workers/limiter/limiter.go b/internal/workers/limiter/limiter.go new file mode 100644 index 0000000..3bad108 --- /dev/null +++ b/internal/workers/limiter/limiter.go @@ -0,0 +1,58 @@ +package limiter + +import ( + "context" + "sync" + "time" +) + +type RateLimiter struct { + requests chan struct{} + done chan struct{} + maxRequests int + Interval time.Duration + mutex sync.Mutex +} + +func NewRateLimiter(ctx context.Context, maxRequests int, interval time.Duration) *RateLimiter { + limiter := &RateLimiter{ + requests: make(chan struct{}, maxRequests), + done: make(chan struct{}), + maxRequests: maxRequests, + Interval: interval, + } + + go limiter.start(ctx) + return limiter +} + +func (limiter *RateLimiter) start(ctx context.Context) { + ticker := time.NewTicker(limiter.Interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + limiter.mutex.Lock() + for i := 0; i < len(limiter.requests); i++ { + <-limiter.requests + } + limiter.mutex.Unlock() + case <-ctx.Done(): + return + } + } +} + +func (limiter *RateLimiter) Check() bool { + select { + case limiter.requests <- struct{}{}: + return true + default: + return false + } +} + +func (limiter *RateLimiter) Stop(_ context.Context) error { + return nil +} diff --git a/internal/workers/post_deals_worker/deals_worker.go b/internal/workers/post_deals_worker/deals_worker.go new file mode 100644 index 0000000..ffcf9c1 --- /dev/null +++ b/internal/workers/post_deals_worker/deals_worker.go @@ -0,0 +1,737 @@ +package post_deals_worker + +import ( + "amocrm/internal/models" + "amocrm/internal/repository" + "amocrm/internal/tools" + "amocrm/pkg/amoClient" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/amo" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/utils" + "strconv" + "strings" + "time" +) + +type Deps struct { + AmoRepo *dal.AmoDal + AmoClient *amoClient.Amo + RedisRepo *repository.Repository + Logger *zap.Logger +} + +type PostDeals struct { + amoRepo *dal.AmoDal + amoClient *amoClient.Amo + redisRepo *repository.Repository + logger *zap.Logger +} + +func NewPostDealsWC(deps Deps) *PostDeals { + return &PostDeals{ + amoRepo: deps.AmoRepo, + amoClient: deps.AmoClient, + redisRepo: deps.RedisRepo, + logger: deps.Logger, + } +} + +func (wc *PostDeals) Start(ctx context.Context) { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + wc.startFetching(ctx) + + case <-ctx.Done(): + return + } + } +} + +func (wc *PostDeals) startFetching(ctx context.Context) { + results, err := wc.amoRepo.AmoRepo.GettingAmoUsersTrueResults(ctx) + if err != nil { + wc.logger.Error("error fetching users answers true results, for sending data to amo", zap.Error(err)) + return + } + + mapDealReq := make(map[string][]models.DealReq) + mapTokenDomain := make(map[string]string) + + for _, result := range results { + userPrivileges, err := wc.amoRepo.AccountRepo.GetPrivilegesByAccountID(ctx, result.QuizAccountID) + if err != nil { + wc.logger.Error("error getting user privileges", zap.Error(err)) + return + } + + if !utils.VerifyUserPrivileges(userPrivileges) { + wc.logger.Info("User don't have active quizCnt or quizUnlim privileges, aborting") + continue + } + + allAnswers, err := wc.amoRepo.AnswerRepo.GetAllAnswersByQuizID(ctx, result.Session) + if err != nil { + wc.logger.Error("error getting all user answers by result session", zap.Error(err)) + return + } + userTags, err := wc.amoRepo.AmoRepo.GetUserTagsByID(ctx, result.AmoAccountID) + if err != nil { + wc.logger.Error("error getting user tags by ano account id", zap.Error(err)) + return + } + + // За один запрос можно передать не более 50 сделок. + deal := models.DealReq{ + Name: fmt.Sprintf("deal quiz number %d", result.QuizID), + StatusID: result.StepID, + PipelineID: result.PipelineID, + CreatedBy: 0, //result.AmoAccountID, + UpdatedBy: 0, + CreatedAt: time.Now().Unix(), + ResponsibleUserID: result.PerformerID, + Embed: models.Embedd{ + Company: []models.Company{}, + Source: models.Source{ + Type: "widget", + }, + Tags: tools.ConstructAmoTags(userTags, result.TagsToAdd), + }, + // строка которая будет возвращенна в респонсе чтоб понимать кто есть что + RequestID: strconv.Itoa(int(result.AnswerID)), + } + + leadFields, contactData, companyData, customerToCreate, err := wc.constructField(ctx, allAnswers, result) + if err != nil { + wc.logger.Error("error construct fields", zap.Error(err)) + return + } + + currentFields, err := wc.amoRepo.AmoRepo.GetUserFieldsByID(ctx, result.AmoAccountID) + if err != nil { + wc.logger.Error("error getting current user fields from db", zap.Error(err)) + return + } + + utmFields := tools.ConstructUTMFields(result.UTMs, currentFields) + + _, err = wc.amoClient.CreatingCustomer(customerToCreate, result.AccessToken, result.SubDomain) + if err != nil { + wc.logger.Error("error sending requests for create customer", zap.Error(err)) + continue + } + + err = wc.redisRepo.CachingLeadFieldsToRedis(ctx, result.AnswerID, leadFields) + if err != nil { + wc.logger.Error("error saving leads fields in redis", zap.Error(err)) + return + } + + deal.Embed.Contact = contactData + deal.Embed.Company = companyData + deal.CustomFieldsValues = utmFields + + wc.logger.Info("NOW DEAL CONSTRUCTED IS:", zap.Any("DEAL", deal)) + + if len(mapDealReq[result.AccessToken]) >= 49 { + wc.logger.Info("reached maximum number of deals for access token", zap.String("access_token", result.AccessToken)) + err = wc.sendingDealsReq(ctx, mapDealReq, mapTokenDomain) + if err != nil { + wc.logger.Error("error sending requests for create deals", zap.Error(err)) + return + } + mapDealReq = make(map[string][]models.DealReq) + } + + mapDealReq[result.AccessToken] = append(mapDealReq[result.AccessToken], deal) + mapTokenDomain[result.AccessToken] = result.SubDomain + } + + err = wc.sendingDealsReq(ctx, mapDealReq, mapTokenDomain) + if err != nil { + wc.logger.Error("error send requests for create deals", zap.Error(err)) + return + } +} + +func (wc *PostDeals) sendingDealsReq(ctx context.Context, mapDealReq map[string][]models.DealReq, mapTokenDomain map[string]string) error { + for accessToken, deal := range mapDealReq { + subDomain := mapTokenDomain[accessToken] + resp, err := wc.amoClient.CreatingDeal(deal, accessToken, subDomain) + if err != nil { + // todo логирование в тг + wc.logger.Error("error creating deal in amo", zap.Error(err)) + return err + } + err = wc.saveDealToDB(ctx, resp, accessToken, subDomain) + if err != nil { + wc.logger.Error("error saving resp data to db", zap.Error(err)) + return err + } + } + return nil +} + +func (wc *PostDeals) saveDealToDB(ctx context.Context, resp []models.DealResp, accessToken string, subDomain string) error { + status := "pending" + for _, dealResp := range resp { + requestID := strings.Join(dealResp.RequestID, ",") + answerID, err := strconv.ParseInt(requestID, 10, 64) + if err != nil { + wc.logger.Error("error converting str requestID to int answerID", zap.Error(err)) + return err + } + + err = wc.amoRepo.AmoRepo.SaveDealAmoStatus(ctx, amo.SaveDealAmoDeps{DealID: dealResp.DealID, AnswerID: answerID, AccessToken: accessToken, Status: status}) + if err != nil { + wc.logger.Error("error saving deal status to database", zap.Error(err)) + return err + } + + err = wc.redisRepo.CachingDealToRedis(ctx, models.SaveDeal{ + AnswerID: answerID, + DealID: dealResp.DealID, + AccessToken: accessToken, + SubDomain: subDomain, + }) + + if err != nil { + wc.logger.Error("error saving deal to redis", zap.Error(err)) + return err + } + } + return nil +} + +func (wc *PostDeals) constructField(ctx context.Context, allAnswers []model.ResultAnswer, result model.AmoUsersTrueResults) ([]models.FieldsValues, []models.Contact, []models.Company, []models.Customer, error) { + dateCreating := time.Now().Unix() + + entityFieldsMap := make(map[model.EntityType]map[int][]models.ValueInterface) + entityFieldsMap[model.LeadsType] = make(map[int][]models.ValueInterface) + entityFieldsMap[model.CompaniesType] = make(map[int][]models.ValueInterface) + entityFieldsMap[model.CustomersType] = make(map[int][]models.ValueInterface) + entityFieldsMap[model.ContactsType] = make(map[int][]models.ValueInterface) + + entityRules := make(map[model.EntityType]map[int]int) + entityRules[model.LeadsType] = result.FieldsRule.Lead.Questionid + entityRules[model.CompaniesType] = result.FieldsRule.Company.Questionid + entityRules[model.CustomersType] = result.FieldsRule.Customer.Questionid + entityRules[model.ContactsType] = result.FieldsRule.Contact.Questionid + + for entityType, rule := range entityRules { + for _, data := range allAnswers { + if fieldID, ok := rule[int(data.QuestionID)]; ok { + + fieldData, err := wc.amoRepo.AmoRepo.GetFieldByID(ctx, int32(fieldID)) + if err != nil { + if err == sql.ErrNoRows { + wc.logger.Info("This field id does not exist in db", zap.Any("fieldID", fieldID)) + continue + } + return nil, nil, nil, nil, err + } + + if fieldData.Type == model.TypeAmoText || fieldData.Type == model.TypeAmoTextarea { + values := entityFieldsMap[entityType][fieldID] + content := strings.ReplaceAll(data.Content, " ", "") + if content == "" { + data.Content = "Пустая строка" + } + values = append(values, models.Values{Value: tools.EmojiUnicode(data.Content)}) + entityFieldsMap[entityType][fieldID] = values + continue + } + + if fieldData.Type == model.TypeFile && data.Content != "" && result.DriveURL != "" { + value, err := wc.amoClient.UploadFileToAmo(data.Content, result.AccessToken, result.DriveURL) + if err != nil { + return nil, nil, nil, nil, err + } + values := entityFieldsMap[entityType][fieldID] + values = append(values, value) + entityFieldsMap[entityType][fieldID] = values + continue + } + } + } + } + + var leadFields []models.FieldsValues + var contactFields []models.FieldsValues + var companyFields []models.FieldsValues + var customerFields []models.FieldsValues + + for entityType, fieldMap := range entityFieldsMap { + for fieldID, values := range fieldMap { + field := models.FieldsValues{ + FieldID: fieldID, + Values: values, + } + switch entityType { + case model.LeadsType: + leadFields = append(leadFields, field) + case model.CompaniesType: + companyFields = append(companyFields, field) + case model.CustomersType: + customerFields = append(customerFields, field) + case model.ContactsType: + contactFields = append(contactFields, field) + } + } + } + + var resultInfo model.ResultContent + err := json.Unmarshal([]byte(result.Content), &resultInfo) + if err != nil { + return nil, nil, nil, nil, err + } + var contactID int32 + contactRuleMap := result.FieldsRule.Contact.ContactRuleMap + + contactFields = tools.AddContactFields(contactFields, resultInfo.Name, model.TypeContactName, contactRuleMap) + if len(resultInfo.Phone) > 4 || resultInfo.Phone != "" { + contactFields = tools.AddContactFields(contactFields, resultInfo.Phone, model.TypeContactPhone, contactRuleMap) + } + contactFields = tools.AddContactFields(contactFields, resultInfo.Text, model.TypeContactText, contactRuleMap) + contactFields = tools.AddContactFields(contactFields, resultInfo.Email, model.TypeContactEmail, contactRuleMap) + contactFields = tools.AddContactFields(contactFields, resultInfo.Address, model.TypeContactAddress, contactRuleMap) + + name := resultInfo.Name + if name == "" { + name = fmt.Sprintf("empty name, quiz %d, triggered by answer - %d", result.QuizID, result.AnswerID) + } + + var fields []string + if len(resultInfo.Phone) > 4 || resultInfo.Phone != "" { + fields = append(fields, resultInfo.Phone) + } + + if resultInfo.Email != "" { + fields = append(fields, resultInfo.Email) + } + + existContactData, err := wc.amoRepo.AmoRepo.GetExistingContactAmo(ctx, result.AmoAccountID, fields) + if err != nil && !errors.Is(err, pj_errors.ErrNotFound) { + return nil, nil, nil, nil, err + } + if errors.Is(err, pj_errors.ErrNotFound) || len(existContactData) == 0 { + fmt.Println("NO CONTACT", contactFields) + contactResp, err := wc.amoClient.CreateContact([]models.CreateContactReq{ + { + Name: resultInfo.Name, + ResponsibleUserID: result.PerformerID, + CreatedBy: 0, + UpdatedBy: 0, + CreatedAt: dateCreating, + CustomFieldsValues: contactFields, + }, + }, result.SubDomain, result.AccessToken) + if err != nil { + return nil, nil, nil, nil, err + } + for _, c := range contactResp.Embedded.Contacts { + contactID = c.ID + } + + if len(resultInfo.Phone) > 4 || resultInfo.Phone != "" { + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contactID, + Field: resultInfo.Phone, + }) + if err != nil { + return nil, nil, nil, nil, err + } + } + + if resultInfo.Email != "" { + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contactID, + Field: resultInfo.Email, + }) + if err != nil { + return nil, nil, nil, nil, err + } + } + } else if existContactData != nil && len(existContactData) > 0 { + contactID, err = wc.chooseAndCreateContact(ctx, result, resultInfo, existContactData, dateCreating, contactFields, contactRuleMap) + if err != nil { + return nil, nil, nil, nil, err + } + } + + return leadFields, []models.Contact{ + { + ID: contactID, + //Name: name, + //ResponsibleUserID: result.PerformerID, + //CreatedBy: 0, + //UpdatedBy: 0, + //CreatedAt: dateCreating, + //CustomFieldsValues: contactFields, + }, + }, []models.Company{ + { + Name: fmt.Sprintf("Компания %d", result.AnswerID), + ResponsibleUserID: result.PerformerID, + CreatedBy: 0, + UpdatedBy: 0, + CreatedAt: dateCreating, + CustomFieldsValues: companyFields, + }, + }, []models.Customer{ + { + // в амо имя покупателя не может быть пустым, надо как то с этим жить + Name: name, + ResponsibleUserID: result.PerformerID, + CreatedBy: 0, + UpdatedBy: 0, + CreatedAt: dateCreating, + CustomFields: customerFields, + RequestID: fmt.Sprint(result.AnswerID), + }, + }, nil +} + +func (wc *PostDeals) chooseAndCreateContact(ctx context.Context, result model.AmoUsersTrueResults, resultInfo model.ResultContent, existingContacts map[int32][]model.ContactAmo, dateCreating int64, contactFields []models.FieldsValues, contactRuleMap map[string]int) (int32, error) { + // 1 ищем контакт в котором совпадает и телефон и емайл + if (len(resultInfo.Phone) > 4 || resultInfo.Phone != "") && resultInfo.Email != "" { + phoneMatchedContacts := make(map[int32]bool) + for _, contactVariants := range existingContacts { + for _, contact := range contactVariants { + if contact.Field == resultInfo.Phone { + phoneMatchedContacts[contact.AmoID] = true + } + } + } + for _, contactVariants := range existingContacts { + for _, contact := range contactVariants { + if contact.Field == resultInfo.Email { + if _, ok := phoneMatchedContacts[contact.AmoID]; ok { + fmt.Println("нашлось телефон и емайл в бд, с одинаковым амоид", resultInfo.Name, resultInfo.Phone, resultInfo.Email) + return contact.AmoID, nil + } + } + } + } + + var phoneContactID, emailContactID int32 + var phoneID int64 /*emailID*/ + for _, contactVariants := range existingContacts { + for _, contact := range contactVariants { + if contact.Field == resultInfo.Phone { + phoneContactID = contact.AmoID + phoneID = contact.ID + } + if contact.Field == resultInfo.Email { + emailContactID = contact.AmoID + //emailID = contact.ID + } + } + } + + if phoneContactID != 0 && emailContactID != 0 && phoneContactID != emailContactID { + fmt.Println("нашлось телефон и емайл в бд, но это пока разные контакты", resultInfo.Name, resultInfo.Phone, resultInfo.Email) + // делаем обновление телефона там где уже есть email + var valuePhone []models.FieldsValues + valuePhone = tools.AddContactFields(valuePhone, resultInfo.Phone, model.TypeContactPhone, contactRuleMap) + name := resultInfo.Name + if name == "" { + name = fmt.Sprintf("empty name, quiz %d, triggered by answer - %d", result.QuizID, result.AnswerID) + } + _, err := wc.amoClient.UpdateContact(models.CreateContactReq{ + Name: name, + UpdatedBy: 0, + ResponsibleUserID: result.PerformerID, + CustomFieldsValues: valuePhone, + }, result.SubDomain, result.AccessToken, emailContactID) + if err != nil { + return 0, err + } + + err = wc.amoRepo.AmoRepo.UpdateAmoContact(ctx, phoneID, resultInfo.Phone, emailContactID) + if err != nil { + return 0, err + } + + // todo пока без линковки + //_, err = wc.amoClient.LinkedContactToContact([]models.LinkedContactReq{ + // { + // ToEntityID: emailContactID, + // ToEntityType: "contacts", + // //Metadata: struct { + // // //CatalogID int `json:"catalog_id"` + // // //Quantity int `json:"quantity"` + // // IsMain bool `json:"is_main"` + // // //UpdatedBy int `json:"updated_by"` + // // //PriceID int `json:"price_id"` + // //}(struct { + // // //CatalogID int + // // //Quantity int + // // IsMain bool + // // //UpdatedBy int + // // //PriceID int + // //}{IsMain: true}), + // }, + //}, result.SubDomain, result.AccessToken, phoneContactID) + //if err != nil { + // return 0, err + //} + + return emailContactID, nil + } + } + + // 2 ищем контакт только по телефону + if len(resultInfo.Phone) > 4 || resultInfo.Phone != "" { + for _, contactVariants := range existingContacts { + for _, contact := range contactVariants { + if contact.Field == resultInfo.Phone { + // нашли контакт по телефону + emailExists := false + for _, variant := range existingContacts[contact.AmoID] { + if variant.Field != contact.Field { + if variant.Field != "" { + emailExists = true + break + } + } + } + if !emailExists && resultInfo.Email != "" { + // email пустой обновляем контакт добавляя email, если не пустой + fmt.Println("нашлось телефон, емайл не пустой, а в бд пустой. обновляем контакт", resultInfo.Name, resultInfo.Phone, resultInfo.Email) + var valueEmail []models.FieldsValues + valueEmail = tools.AddContactFields(valueEmail, resultInfo.Email, model.TypeContactEmail, contactRuleMap) + name := resultInfo.Name + if name == "" { + name = fmt.Sprintf("empty name, quiz %d, triggered by answer - %d", result.QuizID, result.AnswerID) + } + _, err := wc.amoClient.UpdateContact(models.CreateContactReq{ + Name: name, + UpdatedBy: 0, + ResponsibleUserID: result.PerformerID, + CustomFieldsValues: valueEmail, + }, result.SubDomain, result.AccessToken, contact.AmoID) + if err != nil { + return 0, err + } + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contact.AmoID, + Field: resultInfo.Email, + }) + if err != nil { + return 0, err + } + return contact.AmoID, nil + } + if emailExists && resultInfo.Email != "" { + // email не пустой значит это новый контакт создаем если наш email тоже не пустой + fmt.Println("нашлось телефон, емайл не пустой и в бд не пустой. создаем новый контакт", resultInfo.Name, resultInfo.Phone, resultInfo.Email) + name := resultInfo.Name + if name == "" { + name = fmt.Sprintf("empty name, quiz %d, triggered by answer - %d", result.QuizID, result.AnswerID) + } + resp, err := wc.amoClient.CreateContact([]models.CreateContactReq{ + { + Name: name, + ResponsibleUserID: result.PerformerID, + CreatedBy: 0, + UpdatedBy: 0, + CreatedAt: dateCreating, + CustomFieldsValues: contactFields, + }, + }, result.SubDomain, result.AccessToken) + if err != nil { + return 0, err + } + var contactID int32 + for _, c := range resp.Embedded.Contacts { + contactID = c.ID + } + + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contactID, + Field: resultInfo.Phone, + }) + if err != nil { + return 0, err + } + + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contactID, + Field: resultInfo.Email, + }) + if err != nil { + return 0, err + } + return contactID, nil + } + // если пустой то это нужный контакт возвращаем его id, так как если мейл пустой у нас но номер совпадает а в бд не пустой значит оно нам надо + fmt.Println("нашлось телефон, емайл пустой возвращаем существующий контакт", resultInfo.Name, resultInfo.Phone, resultInfo.Email) + return contact.AmoID, nil + } + } + } + } + + // 3 ищем контакт только по email + if resultInfo.Email != "" { + for _, contactVariants := range existingContacts { + for _, contact := range contactVariants { + if contact.Field == resultInfo.Email { + // нашли контакт по email + phoneExists := false + for _, variant := range existingContacts[contact.AmoID] { + if variant.Field != contact.Field { + if variant.Field != "" { + phoneExists = true + break + } + } + } + if !phoneExists && (len(resultInfo.Phone) > 4 || resultInfo.Phone != "") { + // телефон пустой обновляем контакт добавляя телефон, если не пустой + fmt.Println("нашлось емайл, телефон не пустой, а в бд пустой. обновляем контакт", resultInfo.Name, resultInfo.Phone, resultInfo.Email) + var valuePhone []models.FieldsValues + valuePhone = tools.AddContactFields(valuePhone, resultInfo.Phone, model.TypeContactPhone, contactRuleMap) + name := resultInfo.Name + if name == "" { + name = fmt.Sprintf("empty name, quiz %d, triggered by answer - %d", result.QuizID, result.AnswerID) + } + _, err := wc.amoClient.UpdateContact(models.CreateContactReq{ + Name: name, + UpdatedBy: 0, + ResponsibleUserID: result.PerformerID, + CustomFieldsValues: valuePhone, + }, result.SubDomain, result.AccessToken, contact.AmoID) + if err != nil { + return 0, err + } + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contact.AmoID, + Field: resultInfo.Phone, + }) + if err != nil { + return 0, err + } + return contact.AmoID, nil + } + if phoneExists && (len(resultInfo.Phone) > 4 || resultInfo.Phone != "") { + // телефон не пустой значит это новый контакт создаем если наш телефон не пустой + fmt.Println("нашлось емайл, телефон не пустой и в бд не пустой. создаем новый контакт", resultInfo.Name, resultInfo.Phone, resultInfo.Email) + name := resultInfo.Name + if name == "" { + name = fmt.Sprintf("empty name, quiz %d, triggered by answer - %d", result.QuizID, result.AnswerID) + } + resp, err := wc.amoClient.CreateContact([]models.CreateContactReq{ + { + Name: name, + ResponsibleUserID: result.PerformerID, + CreatedBy: 0, + UpdatedBy: 0, + CreatedAt: dateCreating, + CustomFieldsValues: contactFields, + }, + }, result.SubDomain, result.AccessToken) + if err != nil { + return 0, err + } + var contactID int32 + for _, c := range resp.Embedded.Contacts { + contactID = c.ID + } + + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contactID, + Field: resultInfo.Phone, + }) + if err != nil { + return 0, err + } + + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contactID, + Field: resultInfo.Email, + }) + if err != nil { + return 0, err + } + return contactID, nil + } + + // если пустой то это нужный контакт возвращаем его id, так как если телефон пустой у нас но мейл совпадает а в бд не пустой значит оно нам надо + fmt.Println("нашлось емайл, телефон пустой возвращаем существующий контакт", resultInfo.Name, resultInfo.Phone, resultInfo.Email) + return contact.AmoID, nil + } + } + } + } + + fmt.Println("ничего не нашлось, создаем новый контакт", resultInfo.Name, resultInfo.Phone, resultInfo.Email) + // если дошлю до сюда то это новый контакт с новым email and phone + name := resultInfo.Name + if name == "" { + name = fmt.Sprintf("empty name, quiz %d, triggered by answer - %d", result.QuizID, result.AnswerID) + } + resp, err := wc.amoClient.CreateContact([]models.CreateContactReq{ + { + Name: name, + ResponsibleUserID: result.PerformerID, + CreatedBy: 0, + UpdatedBy: 0, + CreatedAt: dateCreating, + CustomFieldsValues: contactFields, + }, + }, result.SubDomain, result.AccessToken) + if err != nil { + return 0, err + } + var contactID int32 + for _, c := range resp.Embedded.Contacts { + contactID = c.ID + } + if len(resultInfo.Phone) > 4 || resultInfo.Phone != "" { + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contactID, + Field: resultInfo.Phone, + }) + if err != nil { + return 0, err + } + } + + if resultInfo.Email != "" { + _, err = wc.amoRepo.AmoRepo.InsertContactAmo(ctx, model.ContactAmo{ + AccountID: result.AmoAccountID, + AmoID: contactID, + Field: resultInfo.Email, + }) + if err != nil { + return 0, err + } + } + return contactID, nil +} + +func (wc *PostDeals) Stop(_ context.Context) error { + return nil +} diff --git a/internal/workers/post_fields_worker/fields_worker.go b/internal/workers/post_fields_worker/fields_worker.go new file mode 100644 index 0000000..e391f0a --- /dev/null +++ b/internal/workers/post_fields_worker/fields_worker.go @@ -0,0 +1,140 @@ +package post_fields_worker + +import ( + "amocrm/internal/models" + "amocrm/internal/repository" + "amocrm/pkg/amoClient" + "context" + "fmt" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/amo" + "time" +) + +type Deps struct { + AmoRepo *dal.AmoDal + AmoClient *amoClient.Amo + RedisRepo *repository.Repository + Logger *zap.Logger +} + +type PostFields struct { + amoRepo *dal.AmoDal + amoClient *amoClient.Amo + redisRepo *repository.Repository + logger *zap.Logger +} + +func NewPostFieldsWC(deps Deps) *PostFields { + return &PostFields{ + amoRepo: deps.AmoRepo, + amoClient: deps.AmoClient, + redisRepo: deps.RedisRepo, + logger: deps.Logger, + } +} + +func (wc *PostFields) Start(ctx context.Context) { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + wc.processTask(ctx) + + case <-ctx.Done(): + return + } + } +} + +func (wc *PostFields) processTask(ctx context.Context) { + dealsDataForUpdate, forRestoringMap, err := wc.redisRepo.FetchingDeals(ctx) + if err != nil { + wc.logger.Error("error fetching deals for update in redis", zap.Error(err)) + return + } + for token, dealsData := range dealsDataForUpdate { + errorCheckerMap, err := wc.sendForUpdate(ctx, token, dealsData) + if err != nil { + wc.logger.Error("error updating deals fields in db", zap.Error(err)) + } + + for dealID, _ := range errorCheckerMap { + restoringData := forRestoringMap[dealID] + err = wc.redisRepo.CachingDealToRedis(ctx, restoringData.SaveDeal) + if err != nil { + wc.logger.Error("error restoring deal in redis", zap.Error(err)) + return + } + + err = wc.redisRepo.CachingLeadFieldsToRedis(ctx, restoringData.SaveDeal.AnswerID, restoringData.LeadFields) + if err != nil { + wc.logger.Error("error restoring deal fields in redis", zap.Error(err)) + return + } + } + } +} + +func (wc *PostFields) sendForUpdate(ctx context.Context, token string, dealsData []models.MappingDealsData) (map[int32]struct{}, error) { + errorCheckerMap := make(map[int32]struct{}) + var subDomain string + var reqToUpdate []models.UpdateDealReq + for _, data := range dealsData { + subDomain = data.SubDomain + req := models.UpdateDealReq{ + DealID: data.DealID, + CustomFieldsValues: data.LeadFields, + } + fmt.Println("AAAA", data.LeadFields) + reqToUpdate = append(reqToUpdate, req) + } + + resp, errResp := wc.amoClient.UpdatingDeal(reqToUpdate, token, subDomain) + if errResp != nil { + // todo также логирование ошибки в тг + wc.logger.Error("error sendig request for update deal fields", zap.Error(errResp)) + for _, data := range reqToUpdate { + errorCheckerMap[data.DealID] = struct{}{} + } + return errorCheckerMap, errResp + } + + err := wc.updateDealStatus(ctx, DealStatus{ + Resp: resp, + AccessToken: token, + }) + + if err != nil { + wc.logger.Error("error update deal Status after updating fields", zap.Error(err)) + return errorCheckerMap, err + } + + return errorCheckerMap, nil +} + +type DealStatus struct { + Resp *models.UpdateDealResp + AccessToken string +} + +func (wc *PostFields) updateDealStatus(ctx context.Context, deps DealStatus) error { + status := "success" + for _, lead := range deps.Resp.Embedded.Leads { + dealID := lead.ID + err := wc.amoRepo.AmoRepo.UpdatingDealAmoStatus(ctx, amo.SaveDealAmoDeps{DealID: dealID, AccessToken: deps.AccessToken, Status: status}) + if err != nil { + wc.logger.Error("error saving deal status update to database", zap.Error(err)) + return err + } + } + + return nil +} + +func (wc *PostFields) Stop(_ context.Context) error { + return nil +} diff --git a/internal/workers/queueUpdater/queue_updater.go b/internal/workers/queueUpdater/queue_updater.go new file mode 100644 index 0000000..dbe27ce --- /dev/null +++ b/internal/workers/queueUpdater/queue_updater.go @@ -0,0 +1,237 @@ +package queueUpdater + +import ( + "amocrm/internal/models" + "amocrm/internal/workers_methods" + "context" + "encoding/json" + "github.com/twmb/franz-go/pkg/kgo" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "time" +) + +type QueueUpdater struct { + logger *zap.Logger + kafkaClient *kgo.Client + methods *workers_methods.Methods +} + +type Deps struct { + Logger *zap.Logger + KafkaClient *kgo.Client + Methods *workers_methods.Methods +} + +func NewQueueUpdater(deps Deps) *QueueUpdater { + return &QueueUpdater{ + logger: deps.Logger, + kafkaClient: deps.KafkaClient, + methods: deps.Methods, + } +} + +func (wc *QueueUpdater) Start(ctx context.Context) { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + wc.consumeMessages(ctx) + + case <-ctx.Done(): + return + } + } +} + +func (wc *QueueUpdater) consumeMessages(ctx context.Context) { + fetches := wc.kafkaClient.PollFetches(ctx) + iter := fetches.RecordIter() + for !iter.Done() { + record := iter.Next() + var message models.KafkaMessage + + err := json.Unmarshal(record.Value, &message) + if err != nil { + wc.logger.Error("error unmarshal kafka message:", zap.Error(err)) + continue + } + + err = wc.processMessages(ctx, message) + if err != nil { + wc.logger.Error("error processing kafka message:", zap.Error(err)) + } + } +} + +func (wc *QueueUpdater) processMessages(ctx context.Context, message models.KafkaMessage) error { + switch message.Type { + case models.UsersUpdate: + token, err := wc.methods.GetTokenByID(ctx, message.AccountID) + if err != nil { + wc.logger.Error("error getting user token from db", zap.Error(err)) + return err + } + + if token != nil { + err = wc.methods.CheckUsers(ctx, []model.Token{*token}) + if err != nil { + wc.logger.Error("error update user information in queue worker", zap.Error(err)) + return err + } + } + + case models.PipelinesUpdate: + token, err := wc.methods.GetTokenByID(ctx, message.AccountID) + if err != nil { + wc.logger.Error("error getting user token from db", zap.Error(err)) + return err + } + + if token != nil { + err = wc.methods.CheckPipelinesAndSteps(ctx, []model.Token{*token}) + if err != nil { + wc.logger.Error("error update user pipelines and steps information in queue worker", zap.Error(err)) + return err + } + } + + case models.FieldsUpdate: + token, err := wc.methods.GetTokenByID(ctx, message.AccountID) + if err != nil { + wc.logger.Error("error getting user token from db", zap.Error(err)) + return err + } + + if token != nil { + err = wc.methods.CheckFields(ctx, []model.Token{*token}) + if err != nil { + wc.logger.Error("error update user fields information in queue worker", zap.Error(err)) + return err + } + } + + case models.TagsUpdate: + token, err := wc.methods.GetTokenByID(ctx, message.AccountID) + if err != nil { + wc.logger.Error("error getting user token from db", zap.Error(err)) + return err + } + + if token != nil { + err = wc.methods.CheckTags(ctx, []model.Token{*token}) + if err != nil { + wc.logger.Error("error update user tags information in queue worker", zap.Error(err)) + return err + } + } + + case models.UserCreate: + token, err := wc.methods.CreateUserFromWebHook(ctx, message) + if err != nil { + wc.logger.Error("error creating user from webhook request", zap.Error(err)) + return err + } + + if token == nil { + return nil + } + + err = wc.methods.CheckUsers(ctx, []model.Token{*token}) + if err != nil { + wc.logger.Error("error update user information in queue worker", zap.Error(err)) + return err + } + + err = wc.methods.CheckPipelinesAndSteps(ctx, []model.Token{*token}) + if err != nil { + wc.logger.Error("error update user pipelines and steps information in queue worker", zap.Error(err)) + return err + } + + err = wc.methods.CheckFields(ctx, []model.Token{*token}) + if err != nil { + wc.logger.Error("error update user fields information in queue worker", zap.Error(err)) + return err + } + + err = wc.methods.CheckTags(ctx, []model.Token{*token}) + if err != nil { + wc.logger.Error("error update user tags information in queue worker", zap.Error(err)) + return err + } + case models.AllDataUpdate: + // сначала получаем список токенов + newTokens, err := wc.methods.UpdateTokens(ctx) + if err != nil { + wc.logger.Error("error updating tokens and getting new tokens", zap.Error(err)) + return err + } + + if len(newTokens) > 0 { + // обновляем информацию о пользователях + err = wc.methods.CheckUsers(ctx, newTokens) + if err != nil { + wc.logger.Error("error update users information", zap.Error(err)) + return err + } + + // обновляем информацию о pipelines и их steps + err = wc.methods.CheckPipelinesAndSteps(ctx, newTokens) + if err != nil { + wc.logger.Error("error updating users pipelines and users pipelines-steps", zap.Error(err)) + return err + } + + // обновляем информацию о tags + err = wc.methods.CheckTags(ctx, newTokens) + if err != nil { + wc.logger.Error("error updating users tags", zap.Error(err)) + return err + } + + // обновляем информацию о fields + err = wc.methods.CheckFields(ctx, newTokens) + if err != nil { + wc.logger.Error("error updating users fields", zap.Error(err)) + return err + } + } + case models.RuleCheck: + token, err := wc.methods.GetTokenByID(ctx, message.AccountID) + if err != nil { + wc.logger.Error("error getting user token from db", zap.Error(err)) + return err + } + if token != nil { + err = wc.methods.CheckFields(ctx, []model.Token{*token}) + if err != nil { + wc.logger.Error("error update user fields information in queue worker", zap.Error(err)) + return err + } + + err = wc.methods.CheckFieldRule(ctx, token.AccessToken, message) + if err != nil { + wc.logger.Error("error check field rules for fields rules", zap.Error(err)) + return err + } + } + case models.UserReLogin: + err := wc.methods.UserReLogin(ctx, message) + if err != nil { + wc.logger.Error("error update user information in re-login method", zap.Error(err)) + return err + } + + default: + wc.logger.Error("incorrect message type", zap.Any("Type:", message)) + return nil + } + return nil +} + +func (wc *QueueUpdater) Stop(_ context.Context) error { + return nil +} diff --git a/internal/workers/tokens/tokens_updater.go b/internal/workers/tokens/tokens_updater.go new file mode 100644 index 0000000..ba4154d --- /dev/null +++ b/internal/workers/tokens/tokens_updater.go @@ -0,0 +1,78 @@ +package tokens + +import ( + "amocrm/pkg/amoClient" + "context" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "time" +) + +type Deps struct { + AmoClient *amoClient.Amo + Repo *dal.AmoDal + Logger *zap.Logger +} + +type Token struct { + amoClient *amoClient.Amo + repo *dal.AmoDal + logger *zap.Logger +} + +func NewRefreshWC(deps Deps) *Token { + return &Token{ + amoClient: deps.AmoClient, + repo: deps.Repo, + logger: deps.Logger, + } +} + +func (wc *Token) Start(ctx context.Context) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + wc.processTasks(ctx) + + case <-ctx.Done(): + return + } + } +} + +func (wc *Token) processTasks(ctx context.Context) { + //tokens, err := wc.repo.AmoRepo.CheckExpired(ctx) + //if err != nil { + // wc.logger.Error("error fetch expired tokens in mongo", zap.Error(err)) + // return + //} + //for _, token := range tokens { + // req := models.UpdateWebHookReq{ + // GrantType: "refresh_token", + // RefreshToken: token.RefreshToken, + // } + // newTokens, err := wc.amoClient.CreateWebHook(&req) + // if err != nil { + // wc.logger.Error("error create webhook for update tokens", zap.Error(err)) + // continue + // } + // err = wc.repo.AmoRepo.WebhookUpdate(ctx, model.Token{ + // AccountID: token.AccountID, + // RefreshToken: newTokens.RefreshToken, + // AccessToken: newTokens.AccessToken, + // Expiration: time.Now().Unix() + newTokens.ExpiresIn, + // CreatedAt: time.Now().Unix(), + // }) + // if err != nil { + // wc.logger.Error("error update new tokens in mongo", zap.Error(err)) + // continue + // } + //} +} + +func (wc *Token) Stop(_ context.Context) error { + return nil +} diff --git a/internal/workers_methods/methods.go b/internal/workers_methods/methods.go new file mode 100644 index 0000000..c9ed034 --- /dev/null +++ b/internal/workers_methods/methods.go @@ -0,0 +1,771 @@ +package workers_methods + +import ( + "amocrm/internal/models" + "amocrm/internal/tools" + "amocrm/pkg/amoClient" + "context" + "encoding/json" + "fmt" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "strings" + "sync" + "time" +) + +type Methods struct { + repo *dal.AmoDal + amoClient *amoClient.Amo + logger *zap.Logger +} + +type Deps struct { + Repo *dal.AmoDal + AmoClient *amoClient.Amo + Logger *zap.Logger +} + +func NewWorkersMethods(deps Deps) *Methods { + return &Methods{ + repo: deps.Repo, + amoClient: deps.AmoClient, + logger: deps.Logger, + } +} + +func (m *Methods) UpdateTokens(ctx context.Context) ([]model.Token, error) { + allTokens, err := m.repo.AmoRepo.GetAllTokens(ctx) + if err != nil { + m.logger.Error("error getting all tokens from db in UpdateTokens", zap.Error(err)) + return nil, err + } + + for _, oldToken := range allTokens { + user, err := m.repo.AmoRepo.GetCurrentAccount(ctx, oldToken.AccountID) + if err != nil { + m.logger.Error("error getting account by id in UpdateTokens", zap.Error(err)) + return nil, err + } + req := models.UpdateWebHookReq{ + GrantType: "refresh_token", + RefreshToken: oldToken.RefreshToken, + } + + resp, err := m.amoClient.CreateWebHook(&req, user.Subdomain) + if err != nil { + m.logger.Error("error create webhook in UpdateTokens", zap.Error(err)) + continue + } + + newToken := model.Token{ + AccountID: oldToken.AccountID, + RefreshToken: resp.RefreshToken, + AccessToken: resp.AccessToken, + Expiration: time.Now().Unix() + resp.ExpiresIn, + CreatedAt: time.Now().Unix(), + } + + err = m.repo.AmoRepo.WebhookUpdate(ctx, newToken) + if err != nil { + m.logger.Error("error update token in db", zap.Error(err)) + return nil, err + } + } + + newTokens, err := m.repo.AmoRepo.GetAllTokens(ctx) + if err != nil { + m.logger.Error("error getting all new updated tokens from db in UpdateTokens", zap.Error(err)) + return nil, err + } + + return newTokens, nil +} + +func (m *Methods) CheckUsers(ctx context.Context, allTokens []model.Token) error { + listUser := make(map[string][]models.Users) + for _, token := range allTokens { + user, err := m.repo.AmoRepo.GetCurrentAccount(ctx, token.AccountID) + if err != nil { + m.logger.Error("error getting account by id in CheckUsers", zap.Error(err)) + return err + } + + page := 1 + limit := 250 + userData, err := m.amoClient.GetUserList(models.RequestGetListUsers{ + Page: page, + Limit: limit, + }, token.AccessToken, user.Subdomain) + if err != nil { + m.logger.Error("error fetching list users", zap.Error(err)) + break + } + + listUser[token.AccountID] = append(listUser[token.AccountID], userData.Embedded.Users...) + } + + for accountID, users := range listUser { + mainAccount, err := m.repo.AmoRepo.GetCurrentAccount(ctx, accountID) + if err != nil { + m.logger.Error("error getting current account from db", zap.Error(err)) + return err + } + + currentUserUsers, err := m.repo.AmoRepo.GetUserUsersByID(ctx, mainAccount.AmoID) + if err != nil { + m.logger.Error("error getting user users by amo user id", zap.Error(err)) + return err + } + + for _, user := range users { + found := false + for _, currentUser := range currentUserUsers { + if user.ID == currentUser.AmoUserID { + found = true + err := m.repo.AmoRepo.UpdateAmoAccountUser(ctx, model.AmoAccountUser{ + AmoID: currentUser.AmoID, + AmoUserID: currentUser.AmoUserID, + Name: user.Name, + Email: user.Email, + Role: int32(user.Rights.RoleID), + Group: int32(user.Rights.GroupID), + }) + if err != nil { + m.logger.Error("failed update user amo account in db", zap.Error(err)) + return err + } + } + } + if !found { + err := m.repo.AmoRepo.AddAmoAccountUser(ctx, model.AmoAccountUser{ + AmoID: mainAccount.AmoID, + AmoUserID: user.ID, + Name: user.Name, + Email: user.Email, + Role: int32(user.Rights.RoleID), + Group: int32(user.Rights.GroupID), + }) + if err != nil { + m.logger.Error("failed insert user amo account in db", zap.Error(err)) + return err + } + } + } + + var deletedUserIDs []int64 + for _, currentUserUser := range currentUserUsers { + found := false + for _, user := range users { + if currentUserUser.AmoUserID == user.ID { + found = true + break + } + } + + if !found { + deletedUserIDs = append(deletedUserIDs, currentUserUser.ID) + } + } + + if len(deletedUserIDs) > 0 { + err := m.repo.AmoRepo.DeleteUsers(ctx, deletedUserIDs) + if err != nil { + m.logger.Error("error deleting users in db", zap.Error(err)) + return err + } + } + } + + return nil +} + +func (m *Methods) CheckPipelinesAndSteps(ctx context.Context, tokens []model.Token) error { + for _, token := range tokens { + user, err := m.repo.AmoRepo.GetCurrentAccount(ctx, token.AccountID) + if err != nil { + m.logger.Error("error getting amoUserInfo by account quiz id", zap.Error(err)) + return err + } + currentUserPipelines, err := m.repo.AmoRepo.GetUserPipelinesByID(ctx, user.AmoID) + if err != nil { + m.logger.Error("error getting user pipelines by amo id", zap.Error(err)) + return err + } + + currentUserSteps, err := m.repo.AmoRepo.GetUserStepsByID(ctx, user.AmoID) + if err != nil { + m.logger.Error("error getting user steps by amo id", zap.Error(err)) + return err + } + + var receivedSteps []model.Step + + pipelines, err := m.amoClient.GetListPipelines(token.AccessToken, user.Subdomain) + if err != nil { + m.logger.Error("error fetching list pipelines from amo", zap.Error(err)) + continue + } + if len(pipelines.Embedded.Pipelines) > 0 { + receivedPipelines := tools.ToPipeline(pipelines.Embedded.Pipelines) + err = m.repo.AmoRepo.CheckPipelines(ctx, receivedPipelines) + if err != nil { + m.logger.Error("error update list pipelines in db:", zap.Error(err)) + return err + } + + for _, pipeline := range pipelines.Embedded.Pipelines { + steps, err := m.amoClient.GetListSteps(pipeline.ID, token.AccessToken, user.Subdomain) + if err != nil { + m.logger.Error("error getting list steps pipeline:", zap.Error(err)) + continue + } + + receivedStep := tools.ToStep(steps.Embedded.Statuses) + receivedSteps = append(receivedSteps, receivedStep...) + err = m.repo.AmoRepo.CheckSteps(ctx, receivedStep) + if err != nil { + m.logger.Error("error update pipeline steps in db:", zap.Error(err)) + return err + } + } + + var deletedPipelineIDs []int64 + for _, currentUserPipeline := range currentUserPipelines { + found := false + for _, receivedPipeline := range receivedPipelines { + if currentUserPipeline.Amoid == receivedPipeline.Amoid && currentUserPipeline.AccountID == user.AmoID { + found = true + break + } + } + if !found { + deletedPipelineIDs = append(deletedPipelineIDs, currentUserPipeline.ID) + } + } + + if len(deletedPipelineIDs) > 0 { + err := m.repo.AmoRepo.DeletePipelines(ctx, deletedPipelineIDs) + if err != nil { + m.logger.Error("error deleting pipelines in db", zap.Error(err)) + return err + } + } + + var deletedStepIDs []int64 + for _, currentUserStep := range currentUserSteps { + found := false + for _, receivedStep := range receivedSteps { + if currentUserStep.Amoid == receivedStep.Amoid && currentUserStep.Accountid == user.AmoID && currentUserStep.Pipelineid == receivedStep.Pipelineid { + found = true + break + } + } + if !found { + deletedStepIDs = append(deletedStepIDs, currentUserStep.ID) + } + } + + if len(deletedStepIDs) > 0 { + err := m.repo.AmoRepo.DeleteSteps(ctx, deletedStepIDs) + if err != nil { + m.logger.Error("error deleting steps in db", zap.Error(err)) + return err + } + } + } + } + + return nil +} + +func (m *Methods) CheckTags(ctx context.Context, tokens []model.Token) error { + for _, token := range tokens { + user, err := m.repo.AmoRepo.GetCurrentAccount(ctx, token.AccountID) + if err != nil { + m.logger.Error("error getting amoUserInfo by account quiz id", zap.Error(err)) + return err + } + + currentUserTags, err := m.repo.AmoRepo.GetUserTagsByID(ctx, user.AmoID) + if err != nil { + m.logger.Error("error getting user tags by amo id", zap.Error(err)) + return err + } + + var wg sync.WaitGroup + wg.Add(4) + + var tagsMap sync.Map + entityTypes := []model.EntityType{model.LeadsType, model.ContactsType, model.CompaniesType, model.CustomersType} + for _, entityType := range entityTypes { + go func(entityType model.EntityType) { + defer wg.Done() + page := 1 + limit := 250 + + for { + req := models.GetListTagsReq{ + Page: page, + Limit: limit, + EntityType: entityType, + } + tags, err := m.amoClient.GetListTags(req, token.AccessToken, user.Subdomain) + if err != nil { + m.logger.Error("error getting list of tags", zap.Error(err)) + return + } + + if tags == nil || len(tags.Embedded.Tags) == 0 { + break + } + + tagsMap.Store(entityType, tags.Embedded.Tags) + + page++ + } + }(entityType) + } + + wg.Wait() + + var deletedTagIDs []int64 + for _, currentUserTag := range currentUserTags { + found := false + for _, entityType := range entityTypes { + if tags, ok := tagsMap.Load(entityType); ok { + if len(tags.([]models.Tag)) > 0 { + receivedTags := tools.ToTag(tags.([]models.Tag), entityType) + for _, tag := range receivedTags { + if currentUserTag.Amoid == tag.Amoid && currentUserTag.Accountid == user.AmoID && currentUserTag.Entity == entityType { + found = true + break + } + } + } + } + if found { + break + } + } + + if !found { + deletedTagIDs = append(deletedTagIDs, currentUserTag.ID) + } + } + + if len(deletedTagIDs) > 0 { + err = m.repo.AmoRepo.DeleteTags(ctx, deletedTagIDs) + if err != nil { + m.logger.Error("error deleting tags in db", zap.Error(err)) + return err + } + } + + for _, entityType := range entityTypes { + if tags, ok := tagsMap.Load(entityType); ok { + if len(tags.([]models.Tag)) > 0 { + err := m.repo.AmoRepo.CheckTags(ctx, tools.ToTag(tags.([]models.Tag), entityType), token.AccountID) + if err != nil { + switch entityType { + case model.LeadsType: + m.logger.Error("error updating leads tags in db", zap.Error(err)) + return err + case model.ContactsType: + m.logger.Error("error updating contacts tags in db", zap.Error(err)) + return err + case model.CompaniesType: + m.logger.Error("error updating companies tags in db", zap.Error(err)) + return err + case model.CustomersType: + m.logger.Error("error updating customer tags in db", zap.Error(err)) + return err + } + } + } + } + } + } + return nil +} + +func (m *Methods) CheckFields(ctx context.Context, tokens []model.Token) error { + for _, token := range tokens { + user, err := m.repo.AmoRepo.GetCurrentAccount(ctx, token.AccountID) + if err != nil { + m.logger.Error("error getting amoUserInfo by account quiz id", zap.Error(err)) + return err + } + + currentUserFields, err := m.repo.AmoRepo.GetUserFieldsByID(ctx, user.AmoID) + if err != nil { + m.logger.Error("error getting user fields by amo id", zap.Error(err)) + return err + } + + var wg sync.WaitGroup + wg.Add(4) + + var fieldsMap sync.Map + entityTypes := []model.EntityType{model.LeadsType, model.ContactsType, model.CompaniesType, model.CustomersType} + for _, entityType := range entityTypes { + go func(entityType model.EntityType) { + defer wg.Done() + page := 1 + limit := 50 + + for { + req := models.GetListFieldsReq{ + Page: page, + Limit: limit, + EntityType: entityType, + } + fields, err := m.amoClient.GetListFields(req, token.AccessToken, user.Subdomain) + if err != nil { + m.logger.Error("error getting list of fields", zap.Error(err)) + return + } + + if fields == nil || len(fields.Embedded.CustomFields) == 0 { + break + } + + fieldsMap.Store(entityType, fields.Embedded.CustomFields) + + page++ + } + }(entityType) + } + + wg.Wait() + + var deletedFieldIDs []int64 + for _, currentUserField := range currentUserFields { + found := false + for _, entityType := range entityTypes { + if fields, ok := fieldsMap.Load(entityType); ok { + if len(fields.([]models.CustomField)) > 0 { + receivedFields := tools.ToField(fields.([]models.CustomField), entityType) + for _, field := range receivedFields { + if currentUserField.Amoid == field.Amoid && currentUserField.Accountid == user.AmoID && currentUserField.Entity == entityType { + found = true + break + } + } + } + } + if found { + break + } + } + + if !found { + deletedFieldIDs = append(deletedFieldIDs, currentUserField.ID) + } + } + + if len(deletedFieldIDs) > 0 { + err = m.repo.AmoRepo.DeleteFields(ctx, deletedFieldIDs) + if err != nil { + m.logger.Error("error deleting fields in db", zap.Error(err)) + return err + } + } + + for _, entityType := range entityTypes { + if fields, ok := fieldsMap.Load(entityType); ok { + if len(fields.([]models.CustomField)) > 0 { + err := m.repo.AmoRepo.CheckFields(ctx, tools.ToField(fields.([]models.CustomField), entityType), token.AccountID) + if err != nil { + switch entityType { + case model.LeadsType: + m.logger.Error("error updating leads fields in db", zap.Error(err)) + return err + case model.ContactsType: + m.logger.Error("error updating contacts fields in db", zap.Error(err)) + return err + case model.CompaniesType: + m.logger.Error("error updating companies fields in db", zap.Error(err)) + return err + case model.CustomersType: + m.logger.Error("error updating customer fields in db", zap.Error(err)) + return err + } + } + } + } + } + } + + return nil +} + +func (m *Methods) GetTokenByID(ctx context.Context, accountID string) (*model.Token, error) { + token, err := m.repo.AmoRepo.GetTokenByID(ctx, accountID) + if err != nil { + m.logger.Error("error getting token by account id from db", zap.Error(err)) + return nil, err + } + + return token, nil +} + +func (m *Methods) CreateUserFromWebHook(ctx context.Context, msg models.KafkaMessage) (*model.Token, error) { + // получаем аксес и рефреш токены по коду авторизации + forGetTokens := models.CreateWebHookReq{ + GrantType: "authorization_code", + Code: msg.AuthCode, + } + + tokens, err := m.amoClient.CreateWebHook(&forGetTokens, msg.RefererURL) + if err != nil { + m.logger.Error("error getting webhook in CreateUserFromWebHook:", zap.Error(err)) + return nil, err + } + + // получаем информацию о пользователе по аксес токену + userInfo, err := m.amoClient.GetUserInfo(tokens.AccessToken, msg.RefererURL) + if err != nil { + m.logger.Error("error getting UserInfo in CreateUserFromWebHook:", zap.Error(err)) + return nil, err + } + + toCreate := model.AmoAccount{ + AccountID: msg.AccountID, + AmoID: userInfo.ID, + Name: userInfo.Name, + Subdomain: msg.RefererURL, + Country: userInfo.Country, + DriveURL: userInfo.DriveUrl, + } + + err = m.repo.AmoRepo.CreateAccount(ctx, toCreate) + if err != nil { + m.logger.Error("error create account in db in CreateUserFromWebHook", zap.Error(err)) + return nil, err + } + + err = m.repo.AmoRepo.WebhookCreate(ctx, model.Token{ + RefreshToken: tokens.RefreshToken, + AccessToken: tokens.AccessToken, + AccountID: msg.AccountID, + AuthCode: msg.AuthCode, + Expiration: time.Now().Unix() + tokens.ExpiresIn, + CreatedAt: time.Now().Unix(), + }) + if err != nil { + m.logger.Error("error adding tokens to db in CreateUserFromWebHook", zap.Error(err)) + return nil, err + } + + return &model.Token{ + AccountID: msg.AccountID, + RefreshToken: tokens.RefreshToken, + AccessToken: tokens.AccessToken, + }, nil +} + +func (m *Methods) CheckFieldRule(ctx context.Context, token string, msg models.KafkaMessage) error { + var ( + leadIDs, companyIDs, customerIDs, contactIDs []int32 + leadQuestions, companyQuestions, customerQuestions, contactQuestions []model.Question + questionsTypeMap = make(map[model.EntityType][]model.Question) + newFields []model.Field + lead, company, customer, contact model.FieldRule + currentFieldsRule = msg.Rule.Fieldsrule + err error + ) + + user, err := m.repo.AmoRepo.GetCurrentAccount(ctx, msg.AccountID) + if err != nil { + m.logger.Error("error getting user data by account id in check utms wc method", zap.Error(err)) + return err + } + + currentFields, err := m.repo.AmoRepo.GetUserFieldsByID(ctx, user.AmoID) + if err != nil { + m.logger.Error("error getting user fields by amo account id", zap.Error(err)) + return err + } + + quiz, err := m.repo.QuizRepo.GetQuizById(ctx, msg.AccountID, uint64(msg.Rule.QuizID)) + if err != nil { + m.logger.Error("error getting quiz by quizID and accountID", zap.Error(err)) + return err + } + + var quizConfig model.QuizContact + err = json.Unmarshal([]byte(quiz.Config), &quizConfig) + if err != nil { + m.logger.Error("error serialization quizConfig to model QuizContact", zap.Error(err)) + return err + } + + leadIDs = tools.ToQuestionIDs(msg.Rule.Fieldsrule.Lead.Questionid) + customerIDs = tools.ToQuestionIDs(msg.Rule.Fieldsrule.Customer.Questionid) + companyIDs = tools.ToQuestionIDs(msg.Rule.Fieldsrule.Company.Questionid) + contactIDs = tools.ToQuestionIDs(msg.Rule.Fieldsrule.Contact.Questionid) + + getQuestions := func(questionIDs []int32, questions *[]model.Question) { + if len(questionIDs) > 0 { + *questions, err = m.repo.QuestionRepo.GetQuestionListByIDs(ctx, questionIDs) + if err != nil { + m.logger.Error("error getting questions", zap.Error(err)) + return + } + } + } + + getQuestions(leadIDs, &leadQuestions) + getQuestions(customerIDs, &customerQuestions) + getQuestions(companyIDs, &companyQuestions) + getQuestions(contactIDs, &contactQuestions) + + questionsTypeMap[model.LeadsType] = append(questionsTypeMap[model.LeadsType], leadQuestions...) + questionsTypeMap[model.CustomersType] = append(questionsTypeMap[model.CustomersType], customerQuestions...) + questionsTypeMap[model.CompaniesType] = append(questionsTypeMap[model.CompaniesType], companyQuestions...) + questionsTypeMap[model.ContactsType] = append(questionsTypeMap[model.ContactsType], contactQuestions...) + + toCreated, toUpdate := tools.ToCreatedUpdateQuestionRules(questionsTypeMap, currentFields) + contactFieldsToCreate, forAdding := tools.ForContactRules(quizConfig, currentFields) + + for entity, fields := range toCreated { + if len(fields) == 0 { + continue + } + + createdFields, err := m.amoClient.AddFields(fields, entity, token, user.Subdomain) + if err != nil { + m.logger.Error("error adding fields to amo", zap.Any("type", entity), zap.Error(err)) + continue + } + newFields = append(newFields, tools.ToField(createdFields.Embedded.CustomFields, entity)...) + } + + if len(contactFieldsToCreate) > 0 { + createdFields, err := m.amoClient.AddFields(contactFieldsToCreate, model.ContactsType, token, user.Subdomain) + if err != nil { + m.logger.Error("error adding fields to amo", zap.Any("type", model.ContactsType), zap.Error(err)) + } + + contructedFields := tools.ToField(createdFields.Embedded.CustomFields, model.ContactsType) + + newFields = append(newFields, contructedFields...) + + for _, field := range contructedFields { + if _, ok := forAdding[field.Name]; ok { + forAdding[field.Name] = int(field.Amoid) + } + } + } + + if len(newFields) > 0 { + err = m.repo.AmoRepo.CheckFields(ctx, newFields, msg.AccountID) + if err != nil { + m.logger.Error("error updating fields rule in db Check Fields", zap.Error(err)) + return err + } + } + + constructFieldRules := func(fieldRuleArrCurrent map[int]int, questions []model.Question, fieldRule *model.FieldRule, currentEntity model.EntityType) { + ruleMap := make(map[int]int) + for questionID, fieldID := range fieldRuleArrCurrent { + if fieldID != 0 { + // если fieldID уже заполнен добавляем его как есть + ruleMap[questionID] = fieldID + continue + } + for _, question := range questions { + if dataQues, ok := toUpdate[questionID]; ok { + if dataQues.Entity == currentEntity { + ruleMap[questionID] = dataQues.FieldID + break + } + } + if questionID == int(question.Id) { + // тут также делаем чтобы сверить филд с вопросом + title := strings.ToLower(strings.ReplaceAll(question.Title, " ", "")) + if title == "" { + question.Title = fmt.Sprintf("Вопрос №%d", question.Page) + } + title = strings.ToLower(strings.ReplaceAll(question.Title, " ", "")) + for _, field := range newFields { + fieldName := strings.ToLower(strings.ReplaceAll(field.Name, " ", "")) + if title == fieldName && field.Entity == currentEntity { + ruleMap[questionID] = int(field.Amoid) + } + } + } + } + } + fieldRule.Questionid = ruleMap + } + + constructFieldRules(currentFieldsRule.Lead.Questionid, leadQuestions, &lead, model.LeadsType) + constructFieldRules(currentFieldsRule.Customer.Questionid, customerQuestions, &customer, model.CustomersType) + constructFieldRules(currentFieldsRule.Company.Questionid, companyQuestions, &company, model.CompaniesType) + constructFieldRules(currentFieldsRule.Contact.Questionid, contactQuestions, &contact, model.ContactsType) + + err = m.repo.AmoRepo.UpdateFieldRules(ctx, model.Fieldsrule{ + Lead: lead, + Customer: customer, + Company: company, + Contact: model.ContactRules{ContactRuleMap: forAdding, Questionid: contact.Questionid}, + }, msg.AccountID, msg.Rule.QuizID) + + if err != nil { + m.logger.Error("error updating fields rule in db", zap.Error(err)) + return err + } + + return nil +} + +func (m *Methods) UserReLogin(ctx context.Context, msg models.KafkaMessage) error { + forGetTokens := models.CreateWebHookReq{ + GrantType: "authorization_code", + Code: msg.AuthCode, + } + + tokens, err := m.amoClient.CreateWebHook(&forGetTokens, msg.RefererURL) + if err != nil { + m.logger.Error("error getting tokens in method user re-login:", zap.Error(err)) + return err + } + + userInfo, err := m.amoClient.GetUserInfo(tokens.AccessToken, msg.RefererURL) + if err != nil { + m.logger.Error("error getting UserInfo in method user re-login:", zap.Error(err)) + return err + } + + toUpdate := model.AmoAccount{ + AccountID: msg.AccountID, + AmoID: userInfo.ID, + Name: userInfo.Name, + Subdomain: msg.RefererURL, + Country: userInfo.Country, + DriveURL: userInfo.DriveUrl, + } + + err = m.repo.AmoRepo.UpdateCurrentAccount(ctx, toUpdate) + if err != nil { + m.logger.Error("error update account in db in method user re-login", zap.Error(err)) + return err + } + + err = m.repo.AmoRepo.WebhookUpdate(ctx, model.Token{ + RefreshToken: tokens.RefreshToken, + AccessToken: tokens.AccessToken, + AccountID: msg.AccountID, + Expiration: time.Now().Unix() + tokens.ExpiresIn, + CreatedAt: time.Now().Unix(), + }) + if err != nil { + m.logger.Error("error update tokens in db in method user re-login", zap.Error(err)) + return err + } + + return nil +} diff --git a/openapi.yaml b/openapi.yaml index 2e19f37..477eec6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4,241 +4,345 @@ info: description: Интеграция с Амо. Ключевая задача - создавать заявки в соответствующей воронкой подключенного аккаунта version: 1.0.0 +tags: + - name: main + description: Операции связанные с AmoCRM + paths: /account: post: + operationId: ConnectAccount description: подключение аккаунта амо к аккаунту quiz. На вход получает только токен. На выход отдаёт ссылку для подключения. Создаёт модель аккаунта, имеющую связь с основным аккаунтом + tags: + - main responses: '200': description: успешное создание ссылки для авторизации content: 'application/json': schema: - type: object - properties: - link: - type: string - description: ссылка для авторизации в амо + $ref: '#/components/schemas/ConnectAccountResp' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' delete: + operationId: SoftDeleteAccount description: мягкое удаление аккаунта. Юзер должен иметь возможность создать новый аккаунт, взамен удалённого + tags: + - main responses: '200': - description: успешное удаление аккаунта + $ref: '#/components/responses/200' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' get: - description: получение текущего аккаунта + operationId: GetCurrentAccount + description: получение текущего аккаунта, компании текщего пользователя + tags: + - main responses: '200': - description: аккаунт интеграции с амо - сontent: + description: аккаунт интеграции амо + content: 'application/json': schema: - type: object - properties: - ID: - type: string - description: uuid - AccountID: - type: string - description: связь с аккаунтом в квизе - AmocrmID: - type: integer - description: связь с аккаунтом в амо - Name: - type: string - description: имя аккаунта в амо - Subdomain: - type: string - description: поддомен организации в амо - AmoUserID: - type: integer - description: айдишник пользвателя, который подключал интеграцию - Country: - type: string - description: страна указанная в настройках амо - CreatedAt: - type: integer - description: таймштамп создания аккаунта + $ref: '#/components/schemas/AccountAmo' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' /webhook/create: get: + operationId: WebhookCreate description: это метод для получения пары токенов для аккаунта. Пары токенов стоит хранить в отдельной таблице и завести воркер, который будет обновлять рефреш. https://www.amocrm.ru/developers/content/oauth/step-by-step - вот дока для этого метода + tags: + - main + responses: + '200': + description: Success /webhook/delete: get: + operationId: WebhookDelete description: это метод для оповещения об удалении итеграции из учетки в амо. При его вызове надо мягко удалить соответствующий аккаунт. https://www.amocrm.ru/developers/content/oauth/step-by-step#%D0%A5%D1%83%D0%BA-%D0%BE%D0%B1-%D0%BE%D1%82%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%BD%D0%B8%D0%B8-%D0%B8%D0%BD%D1%82%D0%B5%D0%B3%D1%80%D0%B0%D1%86%D0%B8%D0%B8 - /utms/{quizID}: - get: - description: получение списка заданных юзером utm меток. Это чисто наша сущность, в амо она представлена кастомными полями сделки - parameters: - $ref: "#/components/parameters/Pagination" + tags: + - main responses: '200': - description: успешное получение списка пользователей - content: - 'application/json': - schema: - type: object - properties: - count: - type: integer - description: общее количество юзеров, которые у нас закешированы для этого пользователя - items: - type: array - description: список юзеров, которые были закешированы нашим сервисом - items: - $ref: "#/components/schemas/UTM" - post: - description: сохранение списка заданных юзером utm меток - requestBody: - content: - 'application/json': - schema: - type: object - properties: - utms: - type: array - description: список utm для сохранения. сохранять только те, которых в этом аккаунте ещё нет - items: - type: string - responses: - '200': - description: успешное сохранение списка utm меток - content: - 'application/json': - schema: - type: array - description: список айдишников сохранённых меток - items: - type: string - delete: - description: удаление utm по айдишникам - requestBody: - content: - 'application/json': - schema: - type: object - properties: - utms: - type: array - description: список айдишников utm которые удалить - items: - type: integer + description: Success /users: get: - description: получение списка юзеров, закешированных у нас, с пагинацией https://www.amocrm.ru/developers/content/crm_platform/users-api#users-list + operationId: GettingUserWithPagination + tags: + - main + description: получение списка юзеров для текущего пользователя и его компании, закешированных у нас, с пагинацией https://www.amocrm.ru/developers/content/crm_platform/users-api#users-list parameters: - $ref: "#/components/parameters/Pagination" + - in: query + name: Pagination + description: Параметры пагинации + required: false + schema: + type: object + properties: + page: + type: integer + description: Номер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 1 + size: + type: integer + description: Размер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 25 + required: + - page + - size responses: '200': - description: успешное получение списка пользователей + description: успешное получение списка пользователей для текущего пользователя и его компании content: 'application/json': schema: - type: object - properties: - count: - type: integer - description: общее количество юзеров, которые у нас закешированы для этого пользователя - items: - type: array - description: список юзеров, которые были закешированы нашим сервисом - items: - $ref: "#/components/schemas/User" + $ref: "#/components/schemas/UserListResp" + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' patch: + operationId: UpdateListUsers description: обновление списка юзеров + tags: + - main + responses: + '200': + $ref: '#/components/responses/200' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' /pipelines: get: + operationId: GetPipelinesWithPagination + tags: + - main description: получение списка воронок, закешированных у нас, с пагинацией https://www.amocrm.ru/developers/content/crm_platform/leads_pipelines parameters: - $ref: "#/components/parameters/Pagination" + - in: query + name: Pagination + description: Параметры пагинации + required: false + schema: + type: object + properties: + page: + type: integer + description: Номер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 1 + size: + type: integer + description: Размер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 25 + required: + - page + - size responses: '200': - description: успешное получение списка воронок + description: успешное получение списка воронок content: 'application/json': schema: - type: object - properties: - count: - type: integer - description: общее количество воронок, которые у нас закешированы для этого пользователя - items: - type: array - description: список воронок, которые были закешированы нашим сервисом - items: - $ref: "#/components/schemas/Pipeline" + $ref: "#/components/schemas/UserListPipelinesResp" + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' patch: + operationId: UpdateListPipelines description: обновление списка воронок + tags: + - main + responses: + '200': + $ref: '#/components/responses/200' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' /steps: get: + operationId: GetStepsWithPagination + tags: + - main description: получение списка этапов воронок, закешированных у нас, с пагинацией https://www.amocrm.ru/developers/content/crm_platform/leads_pipelines#%D0%A1%D0%BF%D0%B8%D1%81%D0%BE%D0%BA-%D1%81%D1%82%D0%B0%D1%82%D1%83%D1%81%D0%BE%D0%B2-%D0%B2%D0%BE%D1%80%D0%BE%D0%BD%D0%BA%D0%B8-%D1%81%D0%B4%D0%B5%D0%BB%D0%BE%D0%BA parameters: - $ref: "#/components/parameters/Pagination" + - in: query + name: Pagination + description: Параметры пагинации + required: false + schema: + type: object + properties: + pipelineID: + type: integer + description: id воронки которой принадлежат шаги + example: 123 + page: + type: integer + description: Номер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 1 + size: + type: integer + description: Размер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 25 + required: + - pipelineID + - page + - size responses: '200': - description: успешное получение списка шагов воронок + description: успешное получение списка шагов воронок content: 'application/json': schema: - type: object - properties: - count: - type: integer - description: общее количество шагов воронок, которые у нас закешированы для этого пользователя - items: - type: array - description: список шагов воронок, которые были закешированы нашим сервисом - items: - $ref: "#/components/schemas/Steps" + $ref: "#/components/schemas/UserListStepsResp" + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' patch: + operationId: UpdateListSteps description: обновление списка этапов воронок + tags: + - main + responses: + '200': + $ref: '#/components/responses/200' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' /fields: get: + operationId: GetFieldsWithPagination + tags: + - main description: получение списка кастомных полей, закешированных у нас, с пагинацией https://www.amocrm.ru/developers/content/crm_platform/custom-fields#%D0%A1%D0%BF%D0%B8%D1%81%D0%BE%D0%BA-%D0%BF%D0%BE%D0%BB%D0%B5%D0%B9-%D1%81%D1%83%D1%89%D0%BD%D0%BE%D1%81%D1%82%D0%B8 parameters: - $ref: "#/components/parameters/Field" + - in: query + name: Pagination + description: Параметры пагинации + required: false + schema: + type: object + properties: + page: + type: integer + description: Номер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 1 + size: + type: integer + description: Размер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 25 + required: + - page + - size responses: '200': - description: успешное получение списка тегов + description: успешное получение списка тегов content: 'application/json': schema: - type: object - properties: - count: - type: integer - description: общее количество кастомных полей, которые у нас закешированы для этого пользователя - items: - type: array - description: список кастомных полей, которые были закешированы нашим сервисом - items: - $ref: "#/components/schemas/Field" + $ref: "#/components/schemas/UserListFieldsResp" + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' patch: + operationId: UpdateListCustom description: обновление списка кастомных полей + tags: + - main + responses: + '200': + $ref: '#/components/responses/200' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' /tags: get: + operationId: GetTagsWithPagination + tags: + - main description: получение списка тегов, закешированных у нас, с пагинацией https://www.amocrm.ru/developers/content/crm_platform/tags-api#%D0%A1%D0%BF%D0%B8%D1%81%D0%BE%D0%BA-%D1%82%D0%B5%D0%B3%D0%BE%D0%B2-%D0%B4%D0%BB%D1%8F-%D1%81%D1%83%D1%89%D0%BD%D0%BE%D1%81%D1%82%D0%B8 parameters: - $ref: "#/components/parameters/Pagination" + - in: query + name: Pagination + description: Параметры пагинации + required: false + schema: + type: object + properties: + page: + type: integer + description: Номер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 1 + size: + type: integer + description: Размер страницы пагинации. Если не указан, используется значение по умолчанию. + example: 25 + required: + - page + - size responses: '200': - description: успешное получение списка тегов + description: успешное получение списка тегов content: 'application/json': schema: - type: object - properties: - count: - type: integer - description: общее количество тегов, которые у нас закешированы для этого пользователя - items: - type: array - description: список тегов, которые были закешированы нашим сервисом - items: - $ref: "#/components/schemas/Tag" + $ref: "#/components/schemas/UserListTagsResp" + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' patch: - description: обновление списка тегов. никакого тела у этого запроса нет, просто он обновляет данные по тегам в приказном порядке, т.е. просто ставит задачу в очередь на переполучение данных от амо + operationId: UpdateListTags + description: обновление списка тегов + tags: + - main + responses: + '200': + $ref: '#/components/responses/200' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' +# таких запросах для блупринта обязательно нужно указывать параметры /rules/{quizID}: - get: + get: + operationId: GettingQuizRules + tags: + - main description: получение настроек интеграции для конкретного квиза + parameters: + - in: path + name: quizID + required: true + schema: + type: string + description: Id квиза responses: '200': description: успешное получение настройки интеграции @@ -246,10 +350,68 @@ paths: 'application/json': schema: $ref: "#/components/schemas/Rule" + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' post: - description: создание настроек интеграции для конкретного квиза. заполнишь сам? тут просто передача всех данных, что можно получить от этого запроса, кроме айдишников, которые генерятся, флага удаления, времени создания и айдишников, получаемых из токена + operationId: SetQuizSettings + description: создание настроек интеграции для конкретного квиза, при успешном исходе кладет задачу в кафку, на 1 квиз 1 правило + tags: + - main + parameters: + - in: path + name: quizID + required: true + schema: + type: string + description: Id квиза + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RulesReq" + responses: + '200': + $ref: '#/components/responses/200' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' patch: - description: изменение настроек интеграции для конкретного квиза + operationId: ChangeQuizSettings + description: изменение настроек интеграции для конкретного квиза, при успешном исходе кладет задачу в кафку, на 1 квиз 1 правило + tags: + - main + parameters: + - in: path + name: quizID + required: true + schema: + type: string + description: Id квиза + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RulesReq" + responses: + '200': + $ref: '#/components/responses/200' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' components: schemas: @@ -259,10 +421,10 @@ components: properties: ID: type: integer - description: айдишник в нашей системе - AccountID: - type: string - description: связь с аккаунтом в интеграции амо + description: айдишник в нашей системе Primary Key + AccountID: + type: integer + description: связь с аккаунтом в интеграции амо id в амо QuizID: type: integer description: айдишник опроса @@ -275,31 +437,50 @@ components: StepID: type: integer description: айдишник этапа - UTMs: - type: array - items: - type: integer - description: список UTM для этого опроса FieldsRule: type: object description: правила заполнения полей сущностей в амо properties: lead: - type: array - items: - $ref: '#/components/schemas/FieldRule' + $ref: '#/components/schemas/FieldRule' contact: - type: array - items: - $ref: '#/components/schemas/FieldRule' + $ref: '#/components/schemas/ContactRules' company: - type: array - items: - $ref: '#/components/schemas/FieldRule' + $ref: '#/components/schemas/FieldRule' customer: + $ref: '#/components/schemas/FieldRule' + TagsToAdd: + type: object + properties: + Lead: type: array items: - $ref: '#/components/schemas/FieldRule' + type: integer + format: int64 + description: Массив ID тегов для Leads, теги с id который наш Primary + Contact: + type: array + items: + type: integer + format: int64 + description: Массив ID тегов для Contacts, теги с id который наш Primary + Company: + type: array + items: + type: integer + format: int64 + description: Массив ID тегов для Company, теги с id который наш Primary + Customer: + type: array + items: + type: integer + format: int64 + description: Массив ID тегов для Customer, теги с id который наш Primary + example: + Lead: [ 101, 102, 103 ] + Contact: [ 201, 202, 203 ] + Company: [ 301, 302, 303 ] + Customer: [ 401, 402, 403 ] Deleted: type: boolean description: флаг мягкого удаления @@ -312,20 +493,22 @@ components: properties: QuestionID: type: integer - description: сопоставление айдишника вопроса полю, которое будет заполняться ответом. соответственно QuestionID это айдишник вопроса. это я так мэпу пытался записать + additionalProperties: + type: integer + description: сопоставление айдишника вопроса полю, которое будет заполняться ответом. соответственно QuestionID это айдишник вопроса. это я так мэпу пытался записать. Мапа, где ключи - QuestionID, значения - ID кастомного поля Pipeline: type: object description: объект воронки амо properties: ID: type: integer - description: айдишник в нашей системе + description: айдишник в нашей системе Primary Key AmoID: type: integer description: айдишник воронки в амо - AccountID: - type: string - description: связь с аккаунтом в интеграции амо + AccountID: + type: integer + description: связь с аккаунтом в интеграции амо id аккаунта в амо Name: type: string description: название воронки в амо @@ -344,16 +527,16 @@ components: properties: ID: type: integer - description: айдишник в нашей системе + description: айдишник в нашей системе Primary Key AmoID: type: integer description: айдишник шага воронки в амо PipelineID: type: integer description: айдишник воронки в амо - AccountID: - type: string - description: связь с аккаунтом в интеграции амо + AccountID: + type: integer + description: связь с аккаунтом в интеграции амо id в амо Name: type: string description: название воронки в амо @@ -372,16 +555,16 @@ components: properties: ID: type: integer - description: айдишник в нашей системе + description: айдишник в нашей системе Primary Key AmoID: type: integer description: айдишник кастомного поля в амо Code: type: string description: кодовое слово в амо - AccountID: - type: string - description: связь с аккаунтом в интеграции амо + AccountID: + type: integer + description: связь с аккаунтом в интеграции амо id аккаунта в амо Name: type: string description: название воронки в амо @@ -403,16 +586,16 @@ components: properties: ID: type: integer - description: айдишник в нашей системе + description: айдишник в нашей системе Primary Key AmoFieldID: type: integer description: айдишник кастомного поля в амо QuizID: type: integer description: айдишник квиза - AccountID: - type: string - description: связь с аккаунтом в интеграции амо + AccountID: + type: integer + description: связь с аккаунтом в интеграции амо id амо Name: type: string description: название тега в амо @@ -428,13 +611,13 @@ components: properties: ID: type: integer - description: айдишник в нашей системе + description: айдишник в нашей системе Primary Key AmoID: type: integer description: айдишник тега в амо - AccountID: - type: string - description: связь с аккаунтом в интеграции амо + AccountID: + type: integer + description: связь с аккаунтом в интеграции амо id аккаунта в амо Entity: type: string description: сущность, к которой принадлежит этот тег. Наверное, стоит сделать через enum в базе @@ -450,51 +633,310 @@ components: CreatedAt: type: integer description: таймштамп создания тега в нашей системе - User: + AccountAmo: type: object - description: объект пользователя из амо + description: модель основного аккаунта "компании" амо properties: - ID: + id: type: integer - description: айдишник в нашей системе - AccountID: + format: int64 + description: postgres big serial + accountID: type: string - description: связь с аккаунтом в интеграции амо - AmoID: + description: ID аккаунта нашей системы + amoID: type: integer - description: айдишник пользователя в амо - Name: + format: int32 + description: ID компании в AmoCRM + name: type: string - description: имя пользователя в амо - Email: - type: string - description: почта пользователя из амо - Role: - type: string - description: роль пользователя в амо - Group: - type: string - description: руппа пользователя в амо - Deleted: + description: Название компании + deleted: type: boolean - description: флаг мягкого удаления - CreatedAt: + description: Флаг, указывающий на удаление + createdAt: + type: string + format: date-time + description: Время создания + subdomain: + type: string + description: Поддомен компании в амо + country: + type: string + description: Страна + driveURL: + type: string + description: URL объектного хранилища + stale: + type: boolean + description: флаг "не свежести" если с токенами все в порядке - false, если просрочились то true + UserAmoAccount: + type: object + description: объект пользователя аккаунта амо, который находится под компанией которая подключилась + properties: + id: type: integer - description: таймштамп создания тега в нашей системе - - parameters: - Pagination: - - page: - in: query - description: указание страницы пагинации. Если страница не указана, применять 0 - required: true - schema: - type: integer - example: 1 - - size: - in: query - description: указание размера страницы пагинации. По умолчанию применять 25 - required: true - schema: - type: integer - example: 100 \ No newline at end of file + format: int64 + description: postgres big serial + amoID: + type: integer + format: int32 + description: ID компании в амо, к которой пользователь принадлежит + amoUserID: + type: integer + format: int32 + description: ID пользователя в амо + name: + type: string + description: Имя + email: + type: string + description: Email + role: + type: integer + format: int32 + description: Роль + group: + type: integer + format: int32 + description: Группа + deleted: + type: boolean + description: Флаг, указывающий на удаление + createdAt: + type: string + format: date-time + description: Время создания записи + + ConnectAccountResp: + type: object + properties: + link: + type: string + description: ссылка для авторизации в амо + GetListUserUTMResp: + type: object + properties: + count: + type: integer + description: общее количество юзеров, которые у нас закешированы для этого пользователя + items: + type: array + description: список юзеров, которые были закешированы нашим сервисом + items: + $ref: "#/components/schemas/UTM" + SaveUserListUTMReq: + type: object + properties: + utms: + type: array + description: список utm для сохранения. сохранять только те, которых в этом аккаунте ещё нет + items: + $ref: "#/components/schemas/UTM" + ListSavedIDUTMResp: + type: object + properties: + IDs: + type: array + description: список айдишников сохранённых меток + items: + type: string + ListDeleteUTMIDsReq: + type: object + properties: + utms: + type: array + description: список айдишников utm которые удалить + items: + type: integer + UserListResp: + type: object + properties: + count: + type: integer + description: общее количество юзеров, которые у нас закешированы для этого пользователя и его компании + items: + type: array + description: список юзеров, которые были закешированы нашим сервисом + items: + $ref: "#/components/schemas/UserAmoAccount" + UserListPipelinesResp: + type: object + properties: + count: + type: integer + description: общее количество воронок, которые у нас закешированы для этого пользователя + items: + type: array + description: список воронок, которые были закешированы нашим сервисом + items: + $ref: "#/components/schemas/Pipeline" + UserListStepsResp: + type: object + properties: + count: + type: integer + description: общее количество шагов воронок, которые у нас закешированы для этого пользователя + items: + type: array + description: список шагов воронок, которые были закешированы нашим сервисом + items: + $ref: "#/components/schemas/Step" + UserListFieldsResp: + type: object + properties: + count: + type: integer + description: общее количество кастомных полей, которые у нас закешированы для этого пользователя + items: + type: array + description: список кастомных полей, которые были закешированы нашим сервисом + items: + $ref: "#/components/schemas/Field" + UserListTagsResp: + type: object + properties: + count: + type: integer + description: общее количество тегов, которые у нас закешированы для этого пользователя + items: + type: array + description: список тегов, которые были закешированы нашим сервисом + items: + $ref: "#/components/schemas/Tag" + PaginationReq: + type: object + properties: + page: + type: integer + description: указание страницы пагинации. Если страница не указана, применять 0 + size: + type: integer + description: указание размера страницы пагинации. По умолчанию применять 25 + RulesReq: + type: object + properties: + PerformerID: + type: integer + description: "айдишник ответственного за сделку" + PipelineID: + type: integer + description: "айдишник воронки" + StepID: + type: integer + description: "айдишник этапа" + Fieldsrule: + type: object + properties: + Lead: + $ref: '#/components/schemas/FieldRule' + Contact: + $ref: '#/components/schemas/ContactRules' + Company: + $ref: '#/components/schemas/FieldRule' + Customer: + $ref: '#/components/schemas/FieldRule' + TagsToAdd: + type: object + properties: + Lead: + type: array + items: + type: integer + format: int64 + description: Массив ID тегов для Leads, теги с id который наш Primary + Contact: + type: array + items: + type: integer + format: int64 + description: Массив ID тегов для Contacts, теги с id который наш Primary + Company: + type: array + items: + type: integer + format: int64 + description: Массив ID тегов для Company, теги с id который наш Primary + Customer: + type: array + items: + type: integer + format: int64 + description: Массив ID тегов для Customer, теги с id который наш Primary + example: + Lead: [ 101, 102, 103 ] + Contact: [ 201, 202, 203 ] + Company: [ 301, 302, 303 ] + Customer: [ 401, 402, 403 ] + + ContactRules: + type: object + description: правила заполнения сущности контакта, название поля = id кастомного поля в амо + properties: + ContactRuleMap: + type: string + additionalProperties: + type: integer + QuestionID: + type: integer + additionalProperties: + type: integer + description: сопоставление айдишника вопроса полю, которое будет заполняться ответом. соответственно QuestionID это айдишник вопроса. это я так мэпу пытался записать. Мапа, где ключи - QuestionID, значения - ID кастомного поля + + responses: + '200': + description: Success + content: + application/json: + schema: + type: string + description: Success + '201': + description: Created + content: + application/json: + schema: + type: string + description: Created + '204': + description: No content + content: + application/json: + schema: + type: string + description: No content + '400': + description: Bad Request + content: + application/json: + schema: + type: string + description: Bad Request + '401': + description: Unauthorized + content: + application/json: + schema: + type: string + description: Unauthorized + '403': + description: Forbidden + content: + application/json: + schema: + type: string + description: Forbidden + '404': + description: Not Found + content: + application/json: + schema: + type: string + description: Not Found + '500': + description: Internal Server Error + content: + application/json: + schema: + type: string + description: Internal Server Error diff --git a/pkg/amoClient/amo.go b/pkg/amoClient/amo.go new file mode 100644 index 0000000..c85c31a --- /dev/null +++ b/pkg/amoClient/amo.go @@ -0,0 +1,846 @@ +package amoClient + +import ( + "amocrm/internal/models" + "amocrm/internal/workers/limiter" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +type Amo struct { + fiberClient *fiber.Client + logger *zap.Logger + redirectionURL string + integrationID string + integrationSecret string + rateLimiter *limiter.RateLimiter + fileMutex sync.Mutex +} + +type AmoDeps struct { + FiberClient *fiber.Client + Logger *zap.Logger + RedirectionURL string + IntegrationID string + IntegrationSecret string + RateLimiter *limiter.RateLimiter +} + +func NewAmoClient(deps AmoDeps) *Amo { + if deps.FiberClient == nil { + deps.FiberClient = fiber.AcquireClient() + } + return &Amo{ + fiberClient: deps.FiberClient, + logger: deps.Logger, + redirectionURL: deps.RedirectionURL, + integrationSecret: deps.IntegrationSecret, + integrationID: deps.IntegrationID, + rateLimiter: deps.RateLimiter, + } +} + +// токен должен быть с правами администратора +// https://www.amocrm.ru/developers/content/crm_platform/users-api#users-list +func (a *Amo) GetUserList(req models.RequestGetListUsers, accesToken string, domain string) (*models.ResponseGetListUsers, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/ajax/v3/users?with=rights&page=%d&limit=%d", domain, req.Page, req.Limit) + + agent := a.fiberClient.Get(uri) + agent.Set("Authorization", "Bearer "+accesToken) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err := range errs { + a.logger.Error("error sending request in GetUserList", zap.Error(err)) + } + return nil, fmt.Errorf("request GetUserList failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + switch statusCode { + case fiber.StatusForbidden: + errorMessage := fmt.Sprintf("error GetUserList StatusForbidden - %d", statusCode) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + case fiber.StatusUnauthorized: + errorMessage := fmt.Sprintf("error GetUserList StatusUnauthorized - %d", statusCode) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + default: + errorMessage := fmt.Sprintf("error GetUserList statusCode - %d", statusCode) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + } + + var userListResponse models.ResponseGetListUsers + err := json.Unmarshal(resBody, &userListResponse) + if err != nil { + a.logger.Error("error unmarshal ResponseGetListUsers:", zap.Error(err)) + return nil, err + } + + return &userListResponse, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +// https://www.amocrm.ru/developers/content/oauth/step-by-step +// POST /oauth2/access_token +// тут и создание по коду и обновление по рефрешу в этом клиенте +func (a *Amo) CreateWebHook(req models.WebHookRequest, domain string) (*models.CreateWebHookResp, error) { + for { + if a.rateLimiter.Check() { + req.SetClientID(a.integrationID) + req.SetClientSecret(a.integrationSecret) + req.SetRedirectURL(a.redirectionURL) + bodyBytes, err := json.Marshal(req) + if err != nil { + a.logger.Error("error marshal req in CreateWebHook:", zap.Error(err)) + return nil, err + } + agent := a.fiberClient.Post("https://" + domain + "/oauth2/access_token") + agent.Set("Content-Type", "application/json").Body(bodyBytes) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request in CreateWebHook for create or update tokens", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from CreateWebHook: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var tokens models.CreateWebHookResp + err = json.Unmarshal(resBody, &tokens) + if err != nil { + a.logger.Error("error unmarshal CreateWebHookResp:", zap.Error(err)) + return nil, err + } + + return &tokens, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +// https://www.amocrm.ru/developers/content/crm_platform/leads_pipelines#%D0%A1%D0%BF%D0%B8%D1%81%D0%BE%D0%BA-%D1%81%D1%82%D0%B0%D1%82%D1%83%D1%81%D0%BE%D0%B2-%D0%B2%D0%BE%D1%80%D0%BE%D0%BD%D0%BA%D0%B8-%D1%81%D0%B4%D0%B5%D0%BB%D0%BE%D0%BA +// GET /api/v4/leads/pipelines/{pipeline_id}/statuses +func (a *Amo) GetListSteps(pipelineID int, accessToken string, domain string) (*models.ResponseGetListSteps, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/leads/pipelines/%d/statuses", domain, pipelineID) + agent := a.fiberClient.Get(uri) + agent.Set("Authorization", "Bearer "+accessToken) + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err := range errs { + a.logger.Error("error sending request in GetListSteps", zap.Error(err)) + } + return nil, fmt.Errorf("request GetListSteps failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from GetListSteps: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var listSteps models.ResponseGetListSteps + err := json.Unmarshal(resBody, &listSteps) + if err != nil { + a.logger.Error("error unmarshal ResponseGetListSteps:", zap.Error(err)) + return nil, err + } + + return &listSteps, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +// https://www.amocrm.ru/developers/content/crm_platform/custom-fields#%D0%A1%D0%BF%D0%B8%D1%81%D0%BE%D0%BA-%D0%BF%D0%BE%D0%BB%D0%B5%D0%B9-%D1%81%D1%83%D1%89%D0%BD%D0%BE%D1%81%D1%82%D0%B8 +// GET /api/v4/leads/custom_fields +// GET /api/v4/contacts/custom_fields +// GET /api/v4/companies/custom_fields +// GET /api/v4/customers/custom_fields + +// пока без этих двух +// GET /api/v4/customers/segments/custom_fields +// GET /api/v4/catalogs/{catalog_id}/custom_fields +// эти методы все относятся к одному и тому же, поэтому на вход будет урл и рек стуктура, выход у них один и тот же +func (a *Amo) GetListFields(req models.GetListFieldsReq, accessToken string, domain string) (*models.ResponseGetListFields, error) { + for { + if a.rateLimiter.Check() { + fullURL := fmt.Sprintf("https://%s/api/v4/%s/custom_fields?limit=%d&page=%d", domain, req.EntityType, req.Limit, req.Page) + agent := a.fiberClient.Get(fullURL) + agent.Set("Authorization", "Bearer "+accessToken) + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err := range errs { + a.logger.Error("error sending request in GetListFields", zap.Error(err)) + } + return nil, fmt.Errorf("request GetListFields failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from GetListFields: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var listFields models.ResponseGetListFields + err := json.Unmarshal(resBody, &listFields) + if err != nil { + a.logger.Error("error unmarshal ResponseGetListFields:", zap.Error(err)) + return nil, err + } + + return &listFields, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +// https://www.amocrm.ru/developers/content/crm_platform/tags-api#%D0%A1%D0%BF%D0%B8%D1%81%D0%BE%D0%BA-%D1%82%D0%B5%D0%B3%D0%BE%D0%B2-%D0%B4%D0%BB%D1%8F-%D1%81%D1%83%D1%89%D0%BD%D0%BE%D1%81%D1%82%D0%B8 +// GET /api/v4/{entity_type:leads|contacts|companies|customers}/tags +func (a *Amo) GetListTags(req models.GetListTagsReq, accessToken string, domain string) (*models.ResponseGetListTags, error) { + for { + if a.rateLimiter.Check() { + fullURL := fmt.Sprintf("https://%s/api/v4/%s/tags?", domain, req.EntityType) + + if req.Filter.Name != "" { + fullURL += "&filter[name]=" + url.QueryEscape(req.Filter.Name) + } + if len(req.Filter.ID) > 0 { + for _, id := range req.Filter.ID { + fullURL += fmt.Sprintf("&filter[id][]=%d", id) + } + } + if req.Filter.Query != "" { + fullURL += "&filter[query]=" + url.QueryEscape(req.Filter.Query) + } + + fullURL += fmt.Sprintf("&page=%d&limit=%d", req.Page, req.Limit) + + agent := a.fiberClient.Get(fullURL) + agent.Set("Authorization", "Bearer "+accessToken) + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err := range errs { + a.logger.Error("error sending request in GetListTags", zap.Error(err)) + } + return nil, fmt.Errorf("request GetListTags failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from GetListTags: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var listTags models.ResponseGetListTags + err := json.Unmarshal(resBody, &listTags) + if err != nil { + a.logger.Error("error unmarshal ResponseGetListTags:", zap.Error(err)) + return nil, err + } + + return &listTags, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +// https://www.amocrm.ru/developers/content/crm_platform/account-info +// GET /api/v4/account +func (a *Amo) GetUserInfo(accessToken string, domain string) (*models.AmocrmUserInformation, error) { + for { + if a.rateLimiter.Check() { + url := fmt.Sprintf("https://%s/api/v4/account?with=drive_url", domain) + agent := a.fiberClient.Get(url) + agent.Set("Authorization", "Bearer "+accessToken) + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err := range errs { + a.logger.Error("error sending request in GetUserInfo", zap.Error(err)) + } + return nil, fmt.Errorf("request GetUserInfo failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from GetUserInfo: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var userInfo models.AmocrmUserInformation + err := json.Unmarshal(resBody, &userInfo) + if err != nil { + a.logger.Error("error unmarshal AmocrmUserInformation:", zap.Error(err)) + return nil, err + } + + return &userInfo, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +// https://www.amocrm.ru/developers/content/crm_platform/leads_pipelines#%D0%A1%D0%BF%D0%B8%D1%81%D0%BE%D0%BA-%D0%B2%D0%BE%D1%80%D0%BE%D0%BD%D0%BE%D0%BA-%D1%81%D0%B4%D0%B5%D0%BB%D0%BE%D0%BA +// GET /api/v4/leads/pipelines +func (a *Amo) GetListPipelines(accessToken string, domain string) (*models.PipelineResponse, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/leads/pipelines", domain) + agent := a.fiberClient.Get(uri) + agent.Set("Authorization", "Bearer "+accessToken) + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err := range errs { + a.logger.Error("error sending request in GetListPipelines", zap.Error(err)) + } + return nil, fmt.Errorf("request GetListPipelines failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from GetListPipelines: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var userInfo models.PipelineResponse + err := json.Unmarshal(resBody, &userInfo) + if err != nil { + a.logger.Error("error unmarshal PipelineResponse:", zap.Error(err)) + return nil, err + } + + return &userInfo, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +// токен должен быть с правами администратора +// https://www.amocrm.ru/developers/content/crm_platform/users-api#user-detail +// GET /api/v4/users/{id +func (a *Amo) GetUserByID(id int32, accessToken string, domain string) (*models.OneUserInfo, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/users/%d?with=role,uuid", domain, id) + agent := a.fiberClient.Get(uri) + agent.Set("Authorization", "Bearer "+accessToken) + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err := range errs { + a.logger.Error("error sending request in GetUserByID", zap.Error(err)) + } + return nil, fmt.Errorf("request GetUserByID failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from Get User By ID:%s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var userInfo models.OneUserInfo + err := json.Unmarshal(resBody, &userInfo) + if err != nil { + a.logger.Error("error unmarshal response body in Get User By ID:", zap.Error(err)) + return nil, err + } + + return &userInfo, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +func (a *Amo) AddFields(req []models.AddLeadsFields, entity model.EntityType, accessToken string, domain string) (*models.ResponseGetListFields, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/%s/custom_fields", domain, entity) + bodyBytes, err := json.Marshal(req) + if err != nil { + a.logger.Error("error marshal req in Add Fields:", zap.Error(err)) + return nil, err + } + agent := a.fiberClient.Post(uri) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + agent.Set("Authorization", "Bearer "+accessToken) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request in Add Fields for add fields", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from Add Fields: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var fields models.ResponseGetListFields + err = json.Unmarshal(resBody, &fields) + if err != nil { + a.logger.Error("error unmarshal response body in Add Fields:", zap.Error(err)) + return nil, err + } + + return &fields, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +func (a *Amo) CreatingDeal(req []models.DealReq, accessToken string, domain string) ([]models.DealResp, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/leads/complex", domain) + bodyBytes, err := json.Marshal(req) + if err != nil { + a.logger.Error("error marshal req in Creating Deal:", zap.Error(err)) + return nil, err + } + + agent := a.fiberClient.Post(uri) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + agent.Set("Authorization", "Bearer "+accessToken) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request in Creating Deal for creating deals", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from Creating Deal: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var resp []models.DealResp + err = json.Unmarshal(resBody, &resp) + if err != nil { + a.logger.Error("error unmarshal response body in Creating Deal:", zap.Error(err)) + return nil, err + } + + return resp, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +func (a *Amo) UpdatingDeal(req []models.UpdateDealReq, accessToken string, domain string) (*models.UpdateDealResp, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/leads", domain) + bodyBytes, err := json.Marshal(req) + if err != nil { + a.logger.Error("error marshal req in Updating Deal:", zap.Error(err)) + return nil, err + } + agent := a.fiberClient.Patch(uri) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + agent.Set("Authorization", "Bearer "+accessToken) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request in Updating Deal for updating deals", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from Updating Deal: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode), zap.String("domain", domain), zap.String("token", accessToken)) + return nil, fmt.Errorf(errorMessage) + } + + var resp models.UpdateDealResp + err = json.Unmarshal(resBody, &resp) + if err != nil { + a.logger.Error("error unmarshal response body in Updating Deal:", zap.Error(err)) + return nil, err + } + + return &resp, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +func (a *Amo) CreatingCustomer(req []models.Customer, accessToken string, domain string) (*models.CustomerResp, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/customers", domain) + bodyBytes, err := json.Marshal(req) + if err != nil { + a.logger.Error("error marshal req in Creating Customer:", zap.Error(err)) + return nil, err + } + + agent := a.fiberClient.Post(uri) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + agent.Set("Authorization", "Bearer "+accessToken) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request in Creating Customer for creating customers", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from Creating Customer: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var resp models.CustomerResp + err = json.Unmarshal(resBody, &resp) + if err != nil { + a.logger.Error("error unmarshal response body in Creating Customer:", zap.Error(err)) + return nil, err + } + + return &resp, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +// todo подумать на счет хранилища в амо +func (a *Amo) downloadFile(urlFile string) (*os.File, error) { + var err error + agent := a.fiberClient.Get(urlFile) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request for getting file by url", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from getting file by url: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + fileName := strings.Split(urlFile, "/") + tmpFile, err := os.Create(fileName[len(fileName)-1]) + if err != nil { + return nil, err + } + + _, err = io.Copy(tmpFile, bytes.NewReader(resBody)) + if err != nil { + return nil, err + } + + return tmpFile, nil +} + +func (a *Amo) UploadFileToAmo(urlFile string, accessToken string, driveURL string) (*models.ValuesFile, error) { + a.fileMutex.Lock() + defer a.fileMutex.Unlock() + localFile, err := a.downloadFile(urlFile) + if err != nil { + return nil, err + } + + defer os.Remove(localFile.Name()) + + fileInfo, err := os.Stat(localFile.Name()) + if err != nil { + return nil, err + } + + fileSize := fileInfo.Size() + createSessionData := &models.CreateSession{ + FileName: localFile.Name(), + FileSize: fileSize, + } + + uri := fmt.Sprintf("%s/v1.0/sessions", driveURL) + bodyBytes, err := json.Marshal(createSessionData) + if err != nil { + a.logger.Error("error marshal create session data:", zap.Error(err)) + return nil, err + } + + agent := a.fiberClient.Post(uri) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + agent.Set("Authorization", "Bearer "+accessToken) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request to create session for upload file in amo", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from creating upload file session: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var resp models.UploadSession + err = json.Unmarshal(resBody, &resp) + if err != nil { + a.logger.Error("error unmarshal response body in creating upload file session:", zap.Error(err)) + return nil, err + } + + response, err := a.createPart(resp, localFile) + if err != nil { + a.logger.Error("error create part file sending to amo:", zap.Error(err)) + return nil, err + } + + return &models.ValuesFile{ + Value: models.ValueFile{ + FileUUID: response.UUID, + VersionUUID: response.VersionUUID, + FileName: response.Name, + FileSize: response.Size, + }, + }, nil +} + +func (a *Amo) createPart(uploadData models.UploadSession, file *os.File) (*models.UploadedFile, error) { + defer file.Close() + fileInfo, err := file.Stat() + if err != nil { + return nil, err + } + + fileSize := fileInfo.Size() + + var uploadedFile models.UploadedFile + maxSize := uploadData.MaxPartSize + var remainingSize = fileSize + var start int64 = 0 + + for remainingSize > 0 { + end := start + maxSize + if end > fileSize { + end = fileSize + } + + partSize := end - start + + partFile, err := os.OpenFile(file.Name(), os.O_RDONLY, 0644) + if err != nil { + return nil, err + } + defer partFile.Close() + + _, err = partFile.Seek(start, io.SeekStart) + if err != nil { + return nil, err + } + + buffer := make([]byte, partSize) + _, err = partFile.Read(buffer) + if err != nil { + return nil, err + } + + agent := a.fiberClient.Post(uploadData.UploadURL).Body(buffer) + if err != nil { + return nil, err + } + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request to upload part file to amo", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != http.StatusOK && statusCode != http.StatusAccepted { + return nil, fmt.Errorf("failed to upload part file to amo, status: %d, respBody: %s", statusCode, string(resBody)) + } + + start = end + remainingSize -= partSize + + if statusCode == http.StatusAccepted { + var next struct { + NextUrl string `json:"next_url"` + SessionID int `json:"session_id"` + } + if err := json.Unmarshal(resBody, &next); err != nil { + return nil, err + } + uploadData.UploadURL = next.NextUrl + continue + } + + if err := json.Unmarshal(resBody, &uploadedFile); err != nil { + return nil, err + } + return &uploadedFile, nil + } + + return nil, nil +} + +func (a *Amo) CreateContact(req []models.CreateContactReq, domain, accessToken string) (*models.ContactResponse, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/contacts", domain) + bodyBytes, err := json.Marshal(req) + if err != nil { + a.logger.Error("error marshal req in Creating Contact:", zap.Error(err)) + return nil, err + } + + agent := a.fiberClient.Post(uri) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + agent.Set("Authorization", "Bearer "+accessToken) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request in Creating Contact", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from Creating Contact: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var resp models.ContactResponse + err = json.Unmarshal(resBody, &resp) + if err != nil { + a.logger.Error("error unmarshal response body in Creating Contact:", zap.Error(err)) + return nil, err + } + + return &resp, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +func (a *Amo) UpdateContact(req models.CreateContactReq, domain, accessToken string, idContact int32) (*models.ContactResponse, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/contacts/%d", domain, idContact) + bodyBytes, err := json.Marshal(req) + if err != nil { + a.logger.Error("error marshal req in Update Contact:", zap.Error(err)) + return nil, err + } + + agent := a.fiberClient.Patch(uri) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + agent.Set("Authorization", "Bearer "+accessToken) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request in Update Contact", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from Update Contact: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var resp models.ContactResponse + err = json.Unmarshal(resBody, &resp) + if err != nil { + a.logger.Error("error unmarshal response body in Update Contact:", zap.Error(err)) + return nil, err + } + + return &resp, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} + +func (a *Amo) LinkedContactToContact(req []models.LinkedContactReq, domain, accessToken string, id int32) (*models.LinkedContactResponse, error) { + for { + if a.rateLimiter.Check() { + uri := fmt.Sprintf("https://%s/api/v4/contacts/%d/link", domain, id) + bodyBytes, err := json.Marshal(req) + if err != nil { + a.logger.Error("error marshal req in Linked Contact To Contact:", zap.Error(err)) + return nil, err + } + + agent := a.fiberClient.Post(uri) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + agent.Set("Authorization", "Bearer "+accessToken) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err = range errs { + a.logger.Error("error sending request in Linked Contact To Contact", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from Linked Contact To Contact: %s", string(resBody)) + a.logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var resp models.LinkedContactResponse + err = json.Unmarshal(resBody, &resp) + if err != nil { + a.logger.Error("error unmarshal response body in Linked Contact To Contact:", zap.Error(err)) + return nil, err + } + + return &resp, nil + } + time.Sleep(a.rateLimiter.Interval) + } +} diff --git a/pkg/amoClient/amo_test.go b/pkg/amoClient/amo_test.go new file mode 100644 index 0000000..b53cf7b --- /dev/null +++ b/pkg/amoClient/amo_test.go @@ -0,0 +1,125 @@ +package amoClient + +import ( + "amocrm/internal/models" + "amocrm/internal/workers/limiter" + "context" + "encoding/json" + "fmt" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "testing" + "time" +) + +func Test_CreateWebhook(t *testing.T) { + ctx := context.Background() + cfgLogger := zap.NewDevelopmentConfig() + cfgLogger.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + cfgLogger.EncoderConfig.ConsoleSeparator = " " + + logger, err := cfgLogger.Build() + if err != nil { + fmt.Println(err) + } + + rateLimiter := limiter.NewRateLimiter(ctx, 6, 1500*time.Millisecond) + + amoclient := NewAmoClient(AmoDeps{ + Logger: logger, + RedirectionURL: "https://squiz.pena.digital/squiz/amocrm/oauth", + IntegrationID: "2dbd6329-9be6-41f2-aa5f-964b9e723e49", + IntegrationSecret: "tNK3LwL4ovP0OBK4jKDHJ3646PqRJDOKQYgY6P2t6DCuV8LEzDzszTDY0Fhwmzc8", + RateLimiter: rateLimiter, + }) + + req2 := models.CreateWebHookReq{ + GrantType: "authorization_code", + Code: "def502003378c2a850f6f3b9618ee811a371e0552692060dd206e166244d8508f1df34e4870e12d183777d29e275fc2e1a5680f27f3999715be63a429a40bef4980ed28f03989b2acc90ac4f8a7a11b514a246a564170d0349ea1ec6584ba8f636ad0d856d6e9ed75e472d461ceee40052513335b9738d5782570a75ec7b4cb3c9bfcf564d93a30e548cff96c789b6097f5c4e254139dc829083ccc5395c276e1b29cd001b8f0efa5579b9e989caeaeb895a6602d70254715b969aa5ce8cd91fc379b406877f3d3258702c4f1f8ca6c8b52eed492aec209418801626e50a1b9b04f4346de452f20e7b4d9611a58e8742342481234a161662e35340aba3aedcb1616fac4be6a125fc6d2aa25ab04eed394ed3ee8f9749ed32048ce69a932f83cdd1fe4d8788ac28683698b729b7d4c36ba6a045d3dc488f5da968ddc4837bdb6a26d4e3f5abcb58c8175c3ab20c6c3bad13c613c77ef23484c2a1ebd4a2152168b15f8d21feafa3178cececdbd47f91863d715f5905b0385efa0744692d863a768aa431b07ea667fef134d3c3a749efdc064d74887a889219e68fd34ab435eb761fea6415f4c4760dd8887b8978d62a35e745826edac41019539012592f737ed5ca690b72ce06c7a2486847b95d47a157f0965eaad4839fd7d1927c03c6152f438dd92a465f58e753523965ac127abd7354", + } + resp, err := amoclient.CreateWebHook(&req2, "penadigitaltech.amocrm.ru") + if err != nil { + fmt.Println(err) + } + + fmt.Println(resp) +} + +func Test_CustomersMode(t *testing.T) { + fiberClient := fiber.AcquireClient() + uri := fmt.Sprint("https://penadigitaltech.amocrm.ru/api/v4/customers/mode") + bodyBytes, err := json.Marshal(struct { + Mode string `json:"mode"` + ISenabled bool `json:"is_enabled"` + }{ + Mode: "segments", + ISenabled: true, + }) + if err != nil { + fmt.Println(err) + } + agent := fiberClient.Patch(uri) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + agent.Set("Authorization", "Bearer "+"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6IjFkMmZlNjFlMjFkZGZjOGRmN2MyNWRhYjc3MTJkMDM5MDVhZWEwYzkyYjJmZTU0Njc4OTgzYmUzM2Q0NWExYjQ2YmI1ZDY2MjJkNDA2Y2ZjIn0.eyJhdWQiOiIyZGJkNjMyOS05YmU2LTQxZjItYWE1Zi05NjRiOWU3MjNlNDkiLCJqdGkiOiIxZDJmZTYxZTIxZGRmYzhkZjdjMjVkYWI3NzEyZDAzOTA1YWVhMGM5MmIyZmU1NDY3ODk4M2JlMzNkNDVhMWI0NmJiNWQ2NjIyZDQwNmNmYyIsImlhdCI6MTcxNDY0NDA3OSwibmJmIjoxNzE0NjQ0MDc5LCJleHAiOjE3MTQ3MzA0NzksInN1YiI6IjgxMTA3MjYiLCJncmFudF90eXBlIjoiIiwiYWNjb3VudF9pZCI6MzAyMjg5OTcsImJhc2VfZG9tYWluIjoiYW1vY3JtLnJ1IiwidmVyc2lvbiI6Miwic2NvcGVzIjpbInB1c2hfbm90aWZpY2F0aW9ucyIsImNybSIsIm5vdGlmaWNhdGlvbnMiXSwiaGFzaF91dWlkIjoiZWUyNjFjMDgtOGZiMC00NTkzLTlmNmQtOWVhNDFhZTljNTJhIn0.qUaJwH95LnU3xMA4GMQ0wtK1_vA_bMM8kd5BlRYNpL6ohEhl4CPk8EdR0qXmtBonsh4Z2kwXXAcPtiysZ6XA4kO1JLrgMN3cxthwEO2Z3UxI5O0L5W3DJvCco_4PCbRgUZWxlrR48NxmZ_bWkrhQb07txygvOOhB2T6lpX2CnkDPlS914jYP9QT8BBREEkERbr1zehVSt4NCCXNSC_Tnj9uXOj5GeYnm2Sw5OUXKEJspmDKEOoX4m_FKwlg3ywQfbWKDemVQYuHgmaPalDLLnAC8iydE50NLol07pQvhkK8zOjgz_zif7vENFH4152P9-ltFEvJVmwyqoN23Xuo7Aw") + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + fmt.Printf("request failed: %v", errs[0]) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from AddLeadsFields: %s", string(resBody)) + fmt.Println(errorMessage) + } + + fmt.Println(string(resBody)) +} + +func Test_GetUserInfo(t *testing.T) { + ctx := context.Background() + cfgLogger := zap.NewDevelopmentConfig() + cfgLogger.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + cfgLogger.EncoderConfig.ConsoleSeparator = " " + + logger, err := cfgLogger.Build() + if err != nil { + fmt.Println(err) + } + + rateLimiter := limiter.NewRateLimiter(ctx, 6, 1500*time.Millisecond) + + amoclient := NewAmoClient(AmoDeps{ + Logger: logger, + RedirectionURL: "https://squiz.pena.digital/squiz/amocrm/oauth", + IntegrationID: "2dbd6329-9be6-41f2-aa5f-964b9e723e49", + IntegrationSecret: "tNK3LwL4ovP0OBK4jKDHJ3646PqRJDOKQYgY6P2t6DCuV8LEzDzszTDY0Fhwmzc8", + RateLimiter: rateLimiter, + }) + + resp, err := amoclient.GetUserInfo("eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6IjQxNjM4YTA3YjJkMzI5ZmJlMGJjNTlkMjlhZDFiYmJkZmQ5NDdlYTczYjc2YzkwYjliNDRiN2Q4YzdiOTZjMWE3Mjk1ZWJhNmQ2MjM3MzJjIn0.eyJhdWQiOiIyZGJkNjMyOS05YmU2LTQxZjItYWE1Zi05NjRiOWU3MjNlNDkiLCJqdGkiOiI0MTYzOGEwN2IyZDMyOWZiZTBiYzU5ZDI5YWQxYmJiZGZkOTQ3ZWE3M2I3NmM5MGI5YjQ0YjdkOGM3Yjk2YzFhNzI5NWViYTZkNjIzNzMyYyIsImlhdCI6MTcxNzgxOTIwMiwibmJmIjoxNzE3ODE5MjAyLCJleHAiOjE3MTc5MDU2MDIsInN1YiI6Ijg0MTM5NjkiLCJncmFudF90eXBlIjoiIiwiYWNjb3VudF9pZCI6MzAyMjg5OTcsImJhc2VfZG9tYWluIjoiYW1vY3JtLnJ1IiwidmVyc2lvbiI6Miwic2NvcGVzIjpbInB1c2hfbm90aWZpY2F0aW9ucyIsImZpbGVzIiwiY3JtIiwibm90aWZpY2F0aW9ucyJdLCJoYXNoX3V1aWQiOiI2NTAwNzFmNi04YzhhLTRlMzgtYWQyOC04YmEzMTgyN2Q2ZmQifQ.aXKe3crb56NIj9RBCrzb40jHxfE6e04kMnCZNnYmhjo2WPeStYBeb4meuIDFpskS6bGG_LRmExVmKOR4OnqWvV0wBXpOUrH0nvD_-NPkAiZbNh3viJFCqBJVfao5BEwC8SfcV2u235kgWcTcvk-nvrPUDuCcldF0bOrszACI3d5nDXxoiwgu9jTpuRq88CvxGHvAEECDrRWSsD6WTPMJmR6iIevn79zkC9nh_JC3Ph4_-waRSL3CbVNXFCXoAnD8Py8A1LCFlt9pRW6SXOopIOe25VgklTUkbDG1sPlFZneXqKpcJ72qPvLE7AKWaWkCSydvtAcNTnBLP7sgLUA4tA", "penadigitaltech.amocrm.ru") + assert.NoError(t, err) + + fmt.Println(resp) +} + +func Test_DealGet(t *testing.T) { + url := "https://penadigitaltech.amocrm.ru/api/v4/leads/44690375" + + client := fiber.AcquireClient() + + agent := client.Get(url) + agent.Set("Authorization", "Bearer "+"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6IjkxNjQxYzdiMGEzYTRjZGUzZmUxYmNiZDZiZmNhMDYxNDcyOTczMGIwZDMxNWIyYWFlYWJhYTRmNDAyMzlhYjZhYWNlZjQzMGQwNGIxMmY1In0.eyJhdWQiOiIyZGJkNjMyOS05YmU2LTQxZjItYWE1Zi05NjRiOWU3MjNlNDkiLCJqdGkiOiI5MTY0MWM3YjBhM2E0Y2RlM2ZlMWJjYmQ2YmZjYTA2MTQ3Mjk3MzBiMGQzMTViMmFhZWFiYWE0ZjQwMjM5YWI2YWFjZWY0MzBkMDRiMTJmNSIsImlhdCI6MTcxODY4MzIwMCwibmJmIjoxNzE4NjgzMjAwLCJleHAiOjE3MTg3Njk2MDAsInN1YiI6Ijg0MTM5NjkiLCJncmFudF90eXBlIjoiIiwiYWNjb3VudF9pZCI6MzAyMjg5OTcsImJhc2VfZG9tYWluIjoiYW1vY3JtLnJ1IiwidmVyc2lvbiI6Miwic2NvcGVzIjpbInB1c2hfbm90aWZpY2F0aW9ucyIsImZpbGVzIiwiY3JtIiwibm90aWZpY2F0aW9ucyJdLCJoYXNoX3V1aWQiOiJhMzBjOWQ1MS1hYTM4LTRlMDYtYWNlMy1iYTQ5MmE3NjE1ZmUifQ.mclcK1MHEIYG9nDSO6NdXIyvIKzd-2h7OrmE-7JjWpIj4WO9W6jUsIEwuJs8glbRT1wvf-SBV9p1Di1QSZE2-9k6exi7W6xgzoK1xLeukdcFd3yTEpDXfamBaMIvlAOyJQ8ZjyqE3Y3f983jUiabe_gGAEk8JxgQzkVNtmvjgeaf8qbHyAwPZ98DddrL91airQooEaT3kmqmXo9R1X0TCfMOh_23xqGH7TDrlQ0AuDo-VjUh5Merc_z6atAocSc1HwHpnjHgMj9Ib3KIenDqbeiUk4evtsOpDO2VdLrKQTPicQUDQGxShaiexF0oGVnoclFh2Cymqon_FuQDnnuyvg") + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + fmt.Println(errs) + } + + if statusCode != fiber.StatusOK { + fmt.Println(statusCode) + } + + fmt.Println(string(resBody)) +} diff --git a/pkg/closer/closer.go b/pkg/closer/closer.go new file mode 100644 index 0000000..fdfbaf1 --- /dev/null +++ b/pkg/closer/closer.go @@ -0,0 +1,37 @@ +package closer + +import ( + "context" +) + +type Closer interface { + Close(ctx context.Context) error +} + +type CloserFunc func(ctx context.Context) error + +func (cf CloserFunc) Close(ctx context.Context) error { + return cf(ctx) +} + +type CloserGroup struct { + closers []Closer +} + +func NewCloserGroup() *CloserGroup { + return &CloserGroup{} +} + +func (cg *CloserGroup) Add(c Closer) { + cg.closers = append(cg.closers, c) +} + +func (cg *CloserGroup) Call(ctx context.Context) error { + var closeErr error + for i := len(cg.closers) - 1; i >= 0; i-- { + if err := cg.closers[i].Close(ctx); err != nil && closeErr == nil { + closeErr = err + } + } + return closeErr +} diff --git a/pkg/pena-social-auth/client.go b/pkg/pena-social-auth/client.go new file mode 100644 index 0000000..0b64148 --- /dev/null +++ b/pkg/pena-social-auth/client.go @@ -0,0 +1,66 @@ +package pena_social_auth + +import ( + "encoding/json" + "fmt" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + "net/http" +) + +type Deps struct { + PenaSocialAuthURL string + FiberClient *fiber.Client + Logger *zap.Logger + ReturnURL string +} + +type Client struct { + penaSocialAuthURL string + fiberClient *fiber.Client + logger *zap.Logger + returnURL string +} + +func NewClient(deps Deps) *Client { + if deps.FiberClient == nil { + deps.FiberClient = fiber.AcquireClient() + } + + return &Client{ + penaSocialAuthURL: deps.PenaSocialAuthURL, + fiberClient: deps.FiberClient, + returnURL: deps.ReturnURL, + logger: deps.Logger, + } +} + +type GenAuthURLResp struct { + URL string `json:"url"` +} + +func (c *Client) GenerateAmocrmAuthURL(accountID string) (string, error) { + url := c.penaSocialAuthURL + "?accessToken=" + accountID + "&returnUrl=" + c.returnURL + statusCode, resp, errs := c.fiberClient.Get(url).Bytes() + if len(errs) > 0 { + for _, err := range errs { + c.logger.Error("error sending request in GenerateAmocrmAuthURL", zap.Error(err)) + } + return "", fmt.Errorf("request GenerateAmocrmAuthURL failed: %v", errs[0]) + } + + if statusCode != http.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from GenerateAmocrmAuthURL: %d", statusCode) + c.logger.Error(errorMessage, zap.Int("status", statusCode)) + return "", fmt.Errorf(errorMessage) + } + + var response GenAuthURLResp + err := json.Unmarshal(resp, &response) + if err != nil { + c.logger.Error("error unmarshal GenAuthURLResp:", zap.Error(err)) + return "", err + } + + return response.URL, nil +} diff --git a/template/.gitignore.tmpl b/template/.gitignore.tmpl new file mode 100644 index 0000000..2b70277 --- /dev/null +++ b/template/.gitignore.tmpl @@ -0,0 +1,161 @@ +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,goland,go +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,goland,go + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### GoLand ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf +.idea + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### GoLand Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### VisualStudioCode ### +.vscode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,goland,go diff --git a/template/cmd/{{.ProjectName}}/main.go.tmpl b/template/cmd/{{.ProjectName}}/main.go.tmpl new file mode 100644 index 0000000..a8d8b4f --- /dev/null +++ b/template/cmd/{{.ProjectName}}/main.go.tmpl @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "os/signal" + "syscall" + + "{{.Vars.ProjectName}}/internal/app" + + "{{.Modules.logger.Import}}" + "{{.Modules.logger.ImportCore}}" + + "{{.Modules.env.Import}}" +) + +func main() { + {{.Modules.logger.Declaration "logger"}} + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + var config app.Config + + {{.Modules.env.Declaration "config"}} + + if err := app.Run(ctx, config, logger); err != nil { + {{.Modules.logger.Message "Fatal" "Failed to run app" "Error" "err"}} + } +} \ No newline at end of file diff --git a/template/internal/app/app.go.tmpl b/template/internal/app/app.go.tmpl new file mode 100644 index 0000000..d5ff19d --- /dev/null +++ b/template/internal/app/app.go.tmpl @@ -0,0 +1,67 @@ +package app + +import ( + "context" + + "{{.Modules.logger.Import}}" +) + +{{.Modules.env.Struct}} + +func Run(ctx context.Context, config Config, logger {{.Modules.logger.Type}}) error { + defer func() { + if r := recover(); r != nil { + logger.Error("Recovered from a panic", zap.Any("error", r)) + } + }() + + {{.Modules.logger.Message "info" "App started" "config" "config"}} + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Инициализация репозиториев + {{range $key, $value := .LayersData.Repositories}} + {{$key}}Repository := {{$value.PackageName}}.New{{$value.Name}}Repository() + {{end}} + + // Инициализация сервисов + {{range $key, $value := .LayersData.Services}} + {{$key}}Service := {{$value.PackageName}}.New{{$value.Name}}Service({{$key}}Repository) + {{end}} + + // Инициализация контроллеров + {{range $key, $value := .LayersData.Controllers}} + {{$key}}Controller := {{$value.PackageName}}.New{{$value.Name}}Controller({{$key}}Service) + {{end}} + +// Создание сервера + server := {{.LayersData.ServerData}}.NewServer({{.LayersData.ServerData}}.ServerConfig{ + Controllers: []{{.LayersData.ServerData}}.Controller{ + {{range $key, $value := .LayersData.Controllers}} + {{$key}}Controller, + {{end}} + }, + }) + + go func() { + err := server.Start("Host + : + Port") + if err != nil { + logger.Error("Server startup error", zap.Error(err)) + cancel() + } + }() + + // Вывод маршрутов + server.ListRoutes() + + <-ctx.Done() + + logger.Info("App shutting down gracefully") + + //TODO + // Остановка сервера + + + return nil +} \ No newline at end of file diff --git a/tests/json/json_test.go b/tests/json/json_test.go new file mode 100644 index 0000000..3011b00 --- /dev/null +++ b/tests/json/json_test.go @@ -0,0 +1,79 @@ +package json + +import ( + "amocrm/internal/models" + "amocrm/internal/tools" + "context" + "encoding/json" + "fmt" + "github.com/stretchr/testify/assert" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "testing" +) + +func TestUnmarshalJSON(t *testing.T) { + jsonData := []byte(`[ + { + "field_id": 1, + "values": [ + {"value": "Value1"}, + {"value": {"file_uuid": "123e4567file_uuid", "version_uuid": "123e4567version_uuid", "file_name": "file.pdf", "file_size": 1}} + ] + }, + { + "field_id": 2, + "values": [ + {"value": "Value2"}, + {"value": {"file_uuid": "98765432file_uuid", "version_uuid": "98765432version_uuid", "file_name": "file.wc", "file_size": 2}} + ] + }, + { + "field_id": 3, + "values": [ + {"value": "Value3"}, + {"value": {"file_uuid": "abcdeffile_uuid", "version_uuid": "abcdefversion_uuid", "file_name": "file.txt", "file_size": 3}} + ] + } +] +`) + + var fv []models.FieldsValues + err := json.Unmarshal(jsonData, &fv) + if err != nil { + t.Errorf("UnmarshalJSON failed: %v", err) + } + + for _, f := range fv { + fmt.Println(f) + } + assert.Equal(t, 3, len(fv)) + + jsonAgain, err := json.Marshal(fv) + assert.NoError(t, err) + + fmt.Println(string(jsonAgain)) +} + +func Test_ContactRule(t *testing.T) { + ctx := context.Background() + repo, err := dal.NewAmoDal(ctx, "host=localhost port=35432 user=squiz password=Redalert2 dbname=squiz sslmode=disable") + assert.NoError(t, err) + + accountID := "64f2cd7a7047f28fdabf6d9e" + + quiz, err := repo.QuizRepo.GetQuizById(ctx, accountID, 26166) + assert.NoError(t, err) + + var quizConfig model.QuizContact + err = json.Unmarshal([]byte(quiz.Config), &quizConfig) + assert.NoError(t, err) + + currentFields, err := repo.AmoRepo.GetUserFieldsByID(ctx, 30228997) + assert.NoError(t, err) + + contactFieldsToCreate, forAdding := tools.ForContactRules(quizConfig, currentFields) + + fmt.Println("contactFieldsToCreate", contactFieldsToCreate) + fmt.Println("forAdding", forAdding) +} diff --git a/tests/limiter/limiter_test.go b/tests/limiter/limiter_test.go new file mode 100644 index 0000000..3194ebc --- /dev/null +++ b/tests/limiter/limiter_test.go @@ -0,0 +1,29 @@ +package limiter + +import ( + "amocrm/internal/workers/limiter" + "context" + "fmt" + "testing" + "time" +) + +func Test_Limiter(t *testing.T) { + ctx := context.Background() + rateLimiter := limiter.NewRateLimiter(ctx, 6, 1500*time.Millisecond) + + go func() { + count := 1 + for { + if rateLimiter.Check() { + fmt.Println("GO", count) + count++ + continue + } + fmt.Println("SLEEP") + time.Sleep(1500 * time.Millisecond) + } + }() + + time.Sleep(20 * time.Second) +} diff --git a/tests/repository/repository_test.go b/tests/repository/repository_test.go new file mode 100644 index 0000000..7bc2e6a --- /dev/null +++ b/tests/repository/repository_test.go @@ -0,0 +1,757 @@ +package test + +import ( + "context" + "fmt" + "github.com/stretchr/testify/assert" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "testing" +) + +func Test_Repository(t *testing.T) { + //ctx := context.Background() + //repo, err := dal.NewAmoDal(ctx, "host=localhost port=35432 user=squiz password=Redalert2 dbname=squiz sslmode=disable") + // + //if err != nil { + // fmt.Println("err init postgres") + // panic(err) + //} + + //err = webhookCreate(ctx, repo) + //assert.NoError(t, err) + //err = webhookUpdate(ctx, repo) + //assert.NoError(t, err) + // + //count := 0 + //for { + // if count == 8 { + // break + // } + // time.Sleep(time.Second) + // tokens, err := checkExpired(ctx, repo) + // assert.NoError(t, err) + // if len(tokens) > 0 { + // fmt.Println(tokens) + // count++ + // } + //} + + //err = getAllTokens(ctx, t, repo) + //assert.NoError(t, err) + //err = createUser(ctx, repo) + //assert.NoError(t, err) + //err = updateUser(ctx, repo) + //assert.NoError(t, err) + //err = checkUsers(ctx, repo) + //assert.NoError(t, err) + //err = gettingUserFromCash(ctx, t, repo) + //assert.NoError(t, err) + //err = checkPipelines(ctx, repo) + //assert.NoError(t, err) + //err = gettingPipelinesFromCash(ctx, t, repo) + //assert.NoError(t, err) + //err = checkSteps(ctx, repo) + //assert.NoError(t, err) + //err = gettingStepsFromCash(ctx, t, repo) + //assert.NoError(t, err) + //err = checkTags(ctx, repo) + //assert.NoError(t, err) + //err = gettingTagsFromCash(ctx, t, repo) + //assert.NoError(t, err) + //err = checkFields(ctx, repo) + //assert.NoError(t, err) + //err = gettingFieldsFromCash(ctx, t, repo) + //assert.NoError(t, err) + //err = saveUtms(ctx, repo) + //assert.NoError(t, err) + //err = gettingUserUtm(ctx, t, repo) + //assert.NoError(t, err) + //err = deleteUserUTM(ctx, t, repo) + //assert.NoError(t, err) + //RuleTest(ctx, repo) +} + +//func createUser(ctx context.Context, repo *dal.AmoDal) error { +// for i := 1; i < 10; i++ { +// accID := strconv.Itoa(i) +// err := repo.AmoRepo.CreateAccount(ctx, accID, model.User{ +// AmoID: int32(i), +// Name: "Test" + strconv.Itoa(i), +// Email: "Test" + strconv.Itoa(i) + "@mail.test", +// Amouserid: int32(i), +// Subdomain: "Test", +// Country: "RUSSIA", +// }) +// if err != nil { +// return err +// } +// } +// return nil +//} +// +//func updateUser(ctx context.Context, repo *dal.AmoDal) error { +// var testUserInfo models.AmocrmUserInformation +// err := json.Unmarshal([]byte(jsonUserInfo), &testUserInfo) +// if err != nil { +// return err +// } +// for i := 1; i < 10; i++ { +// accID := strconv.Itoa(i) +// info := model.User{ +// Name: faker.String(), +// Subdomain: "pena", +// AmoID: 666, +// Amouserid: int32(i), +// Email: faker.Email(), +// Group: int32(i), +// Country: "Russia", +// } +// +// if i%2 == 0 { +// info.Role = int32(i) +// info.ID = faker.Int64() +// } +// +// err = repo.AmoRepo.CreateAccount(ctx, accID, info) +// if err != nil { +// return err +// } +// } +// +// return nil +//} +// +//func checkUsers(ctx context.Context, repo *dal.AmoDal) error { +// var testUserInfo models.AmocrmUserInformation +// err := json.Unmarshal([]byte(jsonUserInfo), &testUserInfo) +// if err != nil { +// return err +// } +// for i := 1; i < 10; i++ { +// if i%2 != 0 { +// info := model.User{ +// Name: faker.String(), +// Email: faker.Email(), +// Group: int32(i), +// } +// info.Role = int32(i) +// +// err = repo.AmoRepo.CheckAndUpdateUsers(ctx, info) +// if err != nil { +// return err +// } +// } +// } +// return err +//} +// +//func gettingUserFromCash(ctx context.Context, t *testing.T, repo *dal.AmoDal) error { +// for i := 1; i < 10; i++ { +// req := model.PaginationReq{ +// Page: 1, +// Size: int32(i), +// } +// +// resp, err := repo.AmoRepo.GettingUserWithPagination(ctx, &req, "5") +// if err != nil { +// return err +// } +// +// fmt.Println(len(resp.Items)) +// } +// return nil +//} +// +//func checkPipelines(ctx context.Context, repo *dal.AmoDal) error { +// var testPipeline1 models.PipelineResponse +// err := json.Unmarshal([]byte(jsonPipelines), &testPipeline1) +// if err != nil { +// return err +// } +// +// var testPipeline2 models.PipelineResponse +// err = json.Unmarshal([]byte(jsonPipelines2), &testPipeline2) +// if err != nil { +// return err +// } +// +// for i := 0; i < 9; i++ { +// err = repo.AmoRepo.CheckPipelines(ctx, tools.ToPipeline(testPipeline1.Embedded.Pipelines)) +// if err != nil { +// return err +// } +// } +// +// for i := 0; i < 9; i++ { +// err = repo.AmoRepo.CheckPipelines(ctx, tools.ToPipeline(testPipeline2.Embedded.Pipelines)) +// if err != nil { +// return err +// } +// } +// +// return nil +//} +// +//func gettingPipelinesFromCash(ctx context.Context, t *testing.T, repo *dal.AmoDal) error { +// for i := 1; i < 28; i++ { +// req := model.PaginationReq{ +// Page: 1, +// Size: int32(i), +// } +// +// resp, err := repo.AmoRepo.GetPipelinesWithPagination(ctx, &req, "4") +// if err != nil { +// return err +// } +// +// fmt.Println(resp.Items) +// fmt.Println(len(resp.Items)) +// } +// return nil +//} +// +//func checkSteps(ctx context.Context, repo *dal.AmoDal) error { +// var testStep1 models.ResponseGetListSteps +// err := json.Unmarshal([]byte(jsonStep1), &testStep1) +// if err != nil { +// return err +// } +// +// var testStep2 models.ResponseGetListSteps +// err = json.Unmarshal([]byte(jsonStep2), &testStep2) +// if err != nil { +// return err +// } +// +// for i := 0; i < 9; i++ { +// err = repo.AmoRepo.CheckSteps(ctx, tools.ToStep(testStep1.Embedded.Statuses)) +// if err != nil { +// return err +// } +// } +// +// for i := 0; i < 9; i++ { +// err = repo.AmoRepo.CheckSteps(ctx, tools.ToStep(testStep2.Embedded.Statuses)) +// if err != nil { +// return err +// } +// } +// +// return nil +//} +// +//func gettingStepsFromCash(ctx context.Context, t *testing.T, repo *dal.AmoDal) error { +// for i := 1; i < 46; i++ { +// req := model.PaginationReq{ +// Page: 1, +// Size: int32(i), +// } +// +// resp, err := repo.AmoRepo.GetStepsWithPagination(ctx, &req, "3") +// if err != nil { +// return err +// } +// +// fmt.Println(resp.Items) +// fmt.Println(len(resp.Items)) +// } +// return nil +//} +// +//func checkTags(ctx context.Context, repo *dal.AmoDal) error { +// var testLeadsTags models.ResponseGetListTags +// err := json.Unmarshal([]byte(jsonLeadsTags), &testLeadsTags) +// if err != nil { +// return err +// } +// +// var testLeadsTags2 models.ResponseGetListTags +// err = json.Unmarshal([]byte(jsonLeadsTags2), &testLeadsTags2) +// if err != nil { +// return err +// } +// +// var testContactsTags models.ResponseGetListTags +// err = json.Unmarshal([]byte(jsonContactsTags), &testContactsTags) +// if err != nil { +// return err +// } +// +// var testContactsTags2 models.ResponseGetListTags +// err = json.Unmarshal([]byte(jsonContactsTags2), &testContactsTags2) +// if err != nil { +// return err +// } +// +// var testCompaniesTags models.ResponseGetListTags +// err = json.Unmarshal([]byte(jsonCompaniesTags), &testCompaniesTags) +// if err != nil { +// return err +// } +// +// var testCompaniesTags2 models.ResponseGetListTags +// err = json.Unmarshal([]byte(jsonCompaniesTags2), &testCompaniesTags2) +// if err != nil { +// return err +// } +// +// var testCustomersTags models.ResponseGetListTags +// err = json.Unmarshal([]byte(jsonCustomersTags), &testCustomersTags) +// if err != nil { +// return err +// } +// +// var testCustomersTags2 models.ResponseGetListTags +// err = json.Unmarshal([]byte(jsonCustomersTags2), &testCustomersTags2) +// if err != nil { +// return err +// } +// for i := 0; i < 9; i++ { +// accID := strconv.Itoa(i) +// err = repo.AmoRepo.CheckTags(ctx, tools.ToTag(testLeadsTags.Embedded.Tags, model.LeadsType), accID) +// if err != nil { +// fmt.Println(err) +// } +// err = repo.AmoRepo.CheckTags(ctx, tools.ToTag(testCompaniesTags.Embedded.Tags, model.CompaniesType), accID) +// if err != nil { +// fmt.Println(err) +// } +// err = repo.AmoRepo.CheckTags(ctx, tools.ToTag(testCustomersTags.Embedded.Tags, model.CustomersType), accID) +// if err != nil { +// fmt.Println(err) +// } +// err = repo.AmoRepo.CheckTags(ctx, tools.ToTag(testContactsTags.Embedded.Tags, model.ContactsType), accID) +// if err != nil { +// fmt.Println(err) +// } +// } +// +// for i := 0; i < 9; i++ { +// accID := strconv.Itoa(i) +// err = repo.AmoRepo.CheckTags(ctx, tools.ToTag(testLeadsTags2.Embedded.Tags, model.LeadsType), accID) +// if err != nil { +// fmt.Println(err) +// } +// err = repo.AmoRepo.CheckTags(ctx, tools.ToTag(testCompaniesTags2.Embedded.Tags, model.CompaniesType), accID) +// if err != nil { +// fmt.Println(err) +// } +// err = repo.AmoRepo.CheckTags(ctx, tools.ToTag(testCustomersTags2.Embedded.Tags, model.CustomersType), accID) +// if err != nil { +// fmt.Println(err) +// } +// err = repo.AmoRepo.CheckTags(ctx, tools.ToTag(testContactsTags2.Embedded.Tags, model.ContactsType), accID) +// if err != nil { +// fmt.Println(err) +// } +// if err != nil { +// fmt.Println(err) +// } +// } +// +// return nil +//} +// +//func gettingTagsFromCash(ctx context.Context, t *testing.T, repo *dal.AmoDal) error { +// for i := 1; i < 73; i++ { +// req := model.PaginationReq{ +// Page: 1, +// Size: int32(i), +// } +// +// resp, err := repo.AmoRepo.GetTagsWithPagination(ctx, &req, "2") +// if err != nil { +// return err +// } +// +// fmt.Println(resp.Items) +// fmt.Println(len(resp.Items)) +// } +// return nil +//} +// +//func checkFields(ctx context.Context, repo *dal.AmoDal) error { +// var testLeadsFields models.ResponseGetListFields +// err := json.Unmarshal([]byte(jsonLeadsFields), &testLeadsFields) +// if err != nil { +// return err +// } +// +// var testLeadsFields2 models.ResponseGetListFields +// err = json.Unmarshal([]byte(jsonLeadsFields2), &testLeadsFields2) +// if err != nil { +// return err +// } +// +// var testContactsFields models.ResponseGetListFields +// err = json.Unmarshal([]byte(jsonContactsFields), &testContactsFields) +// if err != nil { +// return err +// } +// +// var testContactsFields2 models.ResponseGetListFields +// err = json.Unmarshal([]byte(jsonContactsFields2), &testContactsFields2) +// if err != nil { +// return err +// } +// +// var testCompaniesFields models.ResponseGetListFields +// err = json.Unmarshal([]byte(jsonCompaniesFields), &testCompaniesFields) +// if err != nil { +// return err +// } +// +// var testCompaniesFields2 models.ResponseGetListFields +// err = json.Unmarshal([]byte(jsonCompaniesFields2), &testCompaniesFields2) +// if err != nil { +// return err +// } +// +// var testCustomersFields models.ResponseGetListFields +// err = json.Unmarshal([]byte(jsonCustomersFields), &testCustomersFields) +// if err != nil { +// return err +// } +// +// var testCustomersFields2 models.ResponseGetListFields +// err = json.Unmarshal([]byte(jsonCustomersFields2), &testCustomersFields2) +// if err != nil { +// return err +// } +// +// for i := 0; i < 9; i++ { +// accID := strconv.Itoa(i) +// err = repo.AmoRepo.CheckFields(ctx, tools.ToField(testLeadsFields.Embedded.CustomFields, model.LeadsType), accID) +// if err != nil { +// return err +// } +// err = repo.AmoRepo.CheckFields(ctx, tools.ToField(testCompaniesFields.Embedded.CustomFields, model.CompaniesType), accID) +// if err != nil { +// return err +// } +// err = repo.AmoRepo.CheckFields(ctx, tools.ToField(testCustomersFields.Embedded.CustomFields, model.CustomersType), accID) +// if err != nil { +// return err +// } +// err = repo.AmoRepo.CheckFields(ctx, tools.ToField(testContactsFields.Embedded.CustomFields, model.ContactsType), accID) +// if err != nil { +// return err +// } +// } +// +// for i := 0; i < 9; i++ { +// accID := strconv.Itoa(i) +// err = repo.AmoRepo.CheckFields(ctx, tools.ToField(testLeadsFields2.Embedded.CustomFields, model.LeadsType), accID) +// if err != nil { +// return err +// } +// err = repo.AmoRepo.CheckFields(ctx, tools.ToField(testCompaniesFields2.Embedded.CustomFields, model.CompaniesType), accID) +// if err != nil { +// return err +// } +// err = repo.AmoRepo.CheckFields(ctx, tools.ToField(testCustomersFields2.Embedded.CustomFields, model.CustomersType), accID) +// if err != nil { +// return err +// } +// err = repo.AmoRepo.CheckFields(ctx, tools.ToField(testContactsFields2.Embedded.CustomFields, model.ContactsType), accID) +// if err != nil { +// return err +// } +// if err != nil { +// return err +// } +// } +// +// return nil +//} +// +//func gettingFieldsFromCash(ctx context.Context, t *testing.T, repo *dal.AmoDal) error { +// for i := 1; i < 73; i++ { +// req := model.PaginationReq{ +// Page: 1, +// Size: int32(i), +// } +// +// resp, err := repo.AmoRepo.GetFieldsWithPagination(ctx, &req, "1") +// if err != nil { +// return err +// } +// +// fmt.Println(resp.Items) +// fmt.Println(len(resp.Items)) +// } +// return nil +//} +// +//func webhookCreate(ctx context.Context, repo *dal.AmoDal) error { +// for i := 1; i < 10; i++ { +// accID := strconv.Itoa(i) +// err := repo.AmoRepo.WebhookCreate(ctx, model.Token{ +// RefreshToken: faker.UUID(), +// AccessToken: faker.UUID(), +// AccountID: accID, +// AuthCode: faker.String(), +// Expiration: time.Now().Unix() + 10, +// CreatedAt: time.Now().Unix(), +// }) +// if err != nil { +// return err +// } +// } +// return nil +//} +// +//func webhookUpdate(ctx context.Context, repo *dal.AmoDal) error { +// var tokens []model.Token +// for i := 1; i < 10; i++ { +// accID := strconv.Itoa(i) +// tokens = append(tokens, model.Token{ +// RefreshToken: faker.UUID(), +// AccessToken: faker.UUID(), +// AccountID: accID, +// Expiration: time.Now().Unix() + int64(i), +// CreatedAt: time.Now().Unix(), +// }) +// } +// +// err := repo.AmoRepo.WebhookUpdate(ctx, tokens) +// if err != nil { +// fmt.Println(err) +// return err +// } +// return nil +//} +// +//func checkExpired(ctx context.Context, repo *dal.AmoDal) ([]model.Token, error) { +// tokens, err := repo.AmoRepo.CheckExpired(ctx) +// if err != nil { +// return nil, err +// } +// +// return tokens, nil +//} +// +//func getAllTokens(ctx context.Context, t *testing.T, repo *dal.AmoDal) error { +// tokens, err := repo.AmoRepo.GetAllTokens(ctx) +// if err != nil { +// return err +// } +// assert.Equal(t, len(tokens), 9) +// +// return nil +//} +// +//func saveUtms(ctx context.Context, repo *dal.AmoDal) error { +// for i := 1; i < 10; i++ { +// var utms []model.UTM +// for j := 1; j < 50; j++ { +// utm := model.UTM{ +// Name: strconv.Itoa(i), +// Quizid: int32(j), +// Accountid: faker.Int32(), +// Amofieldid: int32(j), +// Createdat: time.Now().Unix(), +// } +// utms = append(utms, utm) +// } +// resp, err := repo.AmoRepo.SavingUserUtm(ctx, utms, strconv.Itoa(i)) +// if err != nil { +// return err +// } +// fmt.Println(resp) +// } +// +// return nil +//} +// +//func gettingUserUtm(ctx context.Context, t *testing.T, repo *dal.AmoDal) error { +// for i := 1; i < 50; i++ { +// req := model.PaginationReq{ +// Page: 1, +// Size: int32(i), +// } +// +// resp, err := repo.AmoRepo.GettingUserUtm(ctx, &req, "1", 1) +// if err != nil { +// return err +// } +// +// fmt.Println(resp.Items) +// fmt.Println(len(resp.Items)) +// } +// return nil +//} +// +//func deleteUserUTM(ctx context.Context, t *testing.T, repo *dal.AmoDal) error { +// req := model.PaginationReq{ +// Page: 1, +// Size: 50, +// } +// +// resp, err := repo.AmoRepo.GettingUserUtm(ctx, &req, "5", 5) +// if err != nil { +// return err +// } +// +// var fordetete []int32 +// for _, r := range resp.Items { +// fordetete = append(fordetete, int32(r.ID)) +// } +// err = repo.AmoRepo.DeletingUserUtm(ctx, &model.ListDeleteUTMIDsReq{ +// fordetete, +// }) +// if err != nil { +// return err +// } +// +// for i := 1; i < 50; i++ { +// req := model.PaginationReq{ +// Page: 1, +// Size: int32(i), +// } +// +// resp, err := repo.AmoRepo.GettingUserUtm(ctx, &req, "5", 5) +// if err != nil { +// return err +// } +// +// fmt.Println(resp.Items) +// fmt.Println(len(resp.Items)) +// } +// +// return nil +//} +// +//func RuleTest(ctx context.Context, repo *dal.AmoDal) { +// testMap := make(map[int]int) +// testMap[11111] = 11111 +// err := repo.AmoRepo.SetQuizSettings(ctx, &model.RulesReq{ +// PerformerID: 2, +// PipelineID: 1, +// StepID: 1, +// Utms: make([]int32, 3), +// Fieldsrule: model.Fieldsrule{ +// Lead: []model.FieldRule{ +// { +// Questionid: testMap, +// }, +// }, +// Contact: []model.FieldRule{ +// { +// Questionid: testMap, +// }, +// }, +// Company: []model.FieldRule{ +// { +// Questionid: testMap, +// }, +// }, +// Customer: []model.FieldRule{ +// { +// Questionid: testMap, +// }, +// }, +// }, +// }, "3", 3) +// +// if err != nil { +// fmt.Println(err) +// } +// +// testMap[222] = 3 +// +// err = repo.AmoRepo.ChangeQuizSettings(ctx, &model.RulesReq{ +// PerformerID: 111, +// PipelineID: 111, +// StepID: 111, +// Utms: make([]int32, 10), +// Fieldsrule: model.Fieldsrule{ +// Lead: []model.FieldRule{ +// { +// Questionid: testMap, +// }, +// }, +// Contact: []model.FieldRule{ +// { +// Questionid: testMap, +// }, +// }, +// Company: []model.FieldRule{ +// { +// Questionid: testMap, +// }, +// }, +// Customer: []model.FieldRule{ +// { +// Questionid: testMap, +// }, +// }, +// }, +// }, "3", 3) +// +// if err != nil { +// fmt.Println(err, "UPDATE") +// } +// +// rule3, err := repo.AmoRepo.GettingQuizRules(ctx, 1) +// if err != nil { +// fmt.Println(err) +// } +// fmt.Println(rule3) +//} +// +//func Test_UTM(t *testing.T) { +// ctx := context.Background() +// repo, err := dal.NewAmoDal(ctx, "host=localhost port=35432 user=squiz password=Redalert2 dbname=squiz sslmode=disable") +// +// if err != nil { +// fmt.Println("err init postgres") +// panic(err) +// } +// +// var utms []model.UTM +// +// for i := 0; i < 10; i++ { +// utm := model.UTM{ +// Quizid: int32(i) + 1, +// Accountid: 30228997, +// Name: faker.String(), +// } +// +// utms = append(utms, utm) +// } +// +// utmss, err := repo.AmoRepo.SavingUserUtm(ctx, utms, "64f2cd7a7047f28fdabf6d9e") +// if err != nil { +// fmt.Println(err) +// } +// +// var ids []int32 +// +// for _, id := range utmss.Ids { +// ids = append(ids, int32(id)) +// } +// +// resp, err := repo.AmoRepo.GetUtmsByID(ctx, ids) +// if err != nil { +// fmt.Println(err) +// } +// +// fmt.Println(resp) +//} + +func Test_GetCurrentAccount(t *testing.T) { + ctx := context.Background() + repo, err := dal.NewAmoDal(ctx, "host=localhost port=35432 user=squiz password=Redalert2 dbname=squiz sslmode=disable") + + if err != nil { + fmt.Println("err init postgres") + panic(err) + } + + accountID := "64f2cd7a7047f28fdabf6d9e" + + account, err := repo.AmoRepo.GetCurrentAccount(ctx, accountID) + assert.NoError(t, err) + fmt.Println(account) +} diff --git a/tests/repository/test_vars.go b/tests/repository/test_vars.go new file mode 100644 index 0000000..e31a735 --- /dev/null +++ b/tests/repository/test_vars.go @@ -0,0 +1,1456 @@ +package test + +var jsonUserInfo = `{ + "id": 1231414, + "name": "example", + "subdomain": "example", + "current_user_id": 581651, + "country": "RU", + "customers_mode": "segments", + "is_unsorted_on": true, + "is_loss_reason_enabled": true, + "is_helpbot_enabled": false, + "is_technical_account": false, + "is_api_filter_enabled": true, + "contact_name_display_order": 1, + "amojo_id": "f3c6340d-410e-4ad1-9f7e-c5e663599909", + "uuid": "824f3a59-6154-4edf-ba90-0b5593715d07", + "drive_url": "https://drive-b.amocrm.ru", + "version": 16, + "entity_names": { + "leads": { + "ru": { + "gender": "m", + "plural_form": { + "dative": "клиентам", + "default": "клиенты", + "genitive": "клиентов", + "accusative": "клиентов", + "instrumental": "клиентами", + "prepositional": "клиентах" + }, + "singular_form": { + "dative": "клиенту", + "default": "клиент", + "genitive": "клиента", + "accusative": "клиента", + "instrumental": "клиентом", + "prepositional": "клиенте" + } + }, + "en": { + "singular_form": { + "default": "lead" + }, + "plural_form": { + "default": "leads" + }, + "gender": "f" + }, + "es": { + "singular_form": { + "default": "acuerdo" + }, + "plural_form": { + "default": "acuerdos" + }, + "gender": "m" + } + } + }, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/account" + } + }, + "_embedded": { + "amojo_rights": { + "can_direct": true, + "can_create_groups": true + }, + "users_groups": [ + { + "id": 0, + "name": "Отдел продаж", + "uuid": null + } + ], + "task_types": [ + { + "id": 1, + "name": "Связаться", + "color": null, + "icon_id": null, + "code": "FOLLOW_UP" + }, + { + "id": 2, + "name": "Встреча", + "color": null, + "icon_id": null, + "code": "MEETING" + } + ], + "datetime_settings": { + "date_pattern": "d.m.Y H:i", + "short_date_pattern": "d.m.Y", + "short_time_pattern": "H:i", + "date_formant": "d.m.Y", + "time_format": "H:i:s", + "timezone": "Europe/Moscow", + "timezone_offset": "+03:00" + } + } +}` + +var jsonPipelines = `{ + "_total_items": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines" + } + }, + "_embedded": { + "pipelines": [ + { + "id": 3177727, + "name": "Воронка", + "sort": 1, + "is_main": true, + "is_unsorted_on": true, + "is_archive": false, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727" + } + }, + "_embedded": { + "statuses": [ + { + "id": 32392156, + "name": "Неразобранное", + "sort": 10, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#c1c1c1", + "type": 1, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392156" + } + } + }, + { + "id": 32392159, + "name": "Первичный контакт", + "sort": 20, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#99ccff", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392159" + } + } + }, + { + "id": 32392165, + "name": "Принимают решение", + "sort": 30, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#ffcc66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392165" + } + } + }, + { + "id": 142, + "name": "Успешно реализовано", + "sort": 10000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#CCFF66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/142" + } + } + }, + { + "id": 143, + "name": "Закрыто и не реализовано", + "sort": 11000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#D5D8DB", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/143" + } + } + } + ] + } + } + ] + } +}` + +var jsonPipelines2 = `{ + "_total_items": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines" + } + }, + "_embedded": { + "pipelines": [ + { + "id": 3177727, + "name": "Воронка11", + "sort": 1, + "is_main": true, + "is_unsorted_on": true, + "is_archive": false, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727" + } + }, + "_embedded": { + "statuses": [ + { + "id": 32392156, + "name": "Неразобранное", + "sort": 10, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#c1c1c1", + "type": 1, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392156" + } + } + }, + { + "id": 32392159, + "name": "Первичный контакт", + "sort": 20, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#99ccff", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392159" + } + } + }, + { + "id": 32392165, + "name": "Принимают решение", + "sort": 30, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#ffcc66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392165" + } + } + }, + { + "id": 142, + "name": "Успешно реализовано", + "sort": 10000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#CCFF66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/142" + } + } + }, + { + "id": 143, + "name": "Закрыто и не реализовано", + "sort": 11000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#D5D8DB", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/143" + } + } + } + ] + } + }, + { + "id": 3177728, + "name": "Воронка23", + "sort": 1, + "is_main": true, + "is_unsorted_on": true, + "is_archive": false, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727" + } + }, + "_embedded": { + "statuses": [ + { + "id": 32392156, + "name": "Неразобранное", + "sort": 10, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#c1c1c1", + "type": 1, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392156" + } + } + }, + { + "id": 32392159, + "name": "Первичный контакт", + "sort": 20, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#99ccff", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392159" + } + } + }, + { + "id": 32392165, + "name": "Принимают решение", + "sort": 30, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#ffcc66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392165" + } + } + }, + { + "id": 142, + "name": "Успешно реализовано", + "sort": 10000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#CCFF66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/142" + } + } + }, + { + "id": 143, + "name": "Закрыто и не реализовано", + "sort": 11000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#D5D8DB", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/143" + } + } + } + ] + } + }, + { + "id": 3177729, + "name": "Воронка32", + "sort": 1, + "is_main": true, + "is_unsorted_on": true, + "is_archive": false, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727" + } + }, + "_embedded": { + "statuses": [ + { + "id": 32392156, + "name": "Неразобранное", + "sort": 10, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#c1c1c1", + "type": 1, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392156" + } + } + }, + { + "id": 32392159, + "name": "Первичный контакт", + "sort": 20, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#99ccff", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392159" + } + } + }, + { + "id": 32392165, + "name": "Принимают решение", + "sort": 30, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#ffcc66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392165" + } + } + }, + { + "id": 142, + "name": "Успешно реализовано", + "sort": 10000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#CCFF66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/142" + } + } + }, + { + "id": 143, + "name": "Закрыто и не реализовано", + "sort": 11000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#D5D8DB", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/143" + } + } + } + ] + } + } + ] + } +}` + +var jsonStep1 = `{ + "_total_items": 5, + "_embedded": { + "statuses": [ + { + "id": 32392156, + "name": "Неразобранное", + "sort": 10, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#c1c1c1", + "type": 1, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392156" + } + }, + "descriptions": [] + }, + { + "id": 32392159, + "name": "Первичный контакт", + "sort": 20, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#99ccff", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392159" + } + }, + "descriptions": [ + { + "account_id": 12345678, + "created_at": "2023-05-05 09:38:00", + "created_by": 12345, + "description": "Описание статуса \"Первичный контакт\" для новичка", + "id": 489, + "level": "newbie", + "pipeline_id": 3177727, + "status_id": 32392159, + "updated_at": "2023-05-05 09:38:00", + "updated_by": null + }, + { + "account_id": 12345678, + "created_at": "2023-05-05 09:38:00", + "created_by": 0, + "description": "Описание статуса \"Первичный контакт\" для кандидата", + "id": 491, + "level": "candidate", + "pipeline_id": 3177727, + "status_id": 32392159, + "updated_at": "2023-05-05 09:38:00", + "updated_by": 12345 + }, + { + "account_id": 12345678, + "created_at": "2023-05-05 09:38:00", + "created_by": 123456, + "description": "Описание статуса \"Первичный контакт\" для мастера", + "id": 493, + "level": "master", + "pipeline_id": 3177727, + "status_id": 32392159, + "updated_at": "2023-05-05 09:38:00", + "updated_by": 123457 + } + ] + }, + { + "id": 32392165, + "name": "Принимают решение", + "sort": 30, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#ffcc66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392165" + } + }, + "descriptions": [] + }, + { + "id": 142, + "name": "Успешно реализовано", + "sort": 10000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#CCFF66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/142" + } + }, + "descriptions": [] + }, + { + "id": 143, + "name": "Закрыто и не реализовано", + "sort": 11000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#D5D8DB", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/143" + } + }, + "descriptions": [] + } + ] + } +}` + +var jsonStep2 = `{ + "_total_items": 5, + "_embedded": { + "statuses": [ + { + "id": 32392156, + "name": "Неразобранное update", + "sort": 10, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#c1c1c1", + "type": 1, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392156" + } + }, + "descriptions": [] + }, + { + "id": 32392159, + "name": "Первичный контакт update", + "sort": 20, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#99ccff", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392159" + } + }, + "descriptions": [ + { + "account_id": 12345678, + "created_at": "2023-05-05 09:38:00", + "created_by": 12345, + "description": "Описание статуса \"Первичный контакт\" для новичка", + "id": 489, + "level": "newbie", + "pipeline_id": 3177727, + "status_id": 32392159, + "updated_at": "2023-05-05 09:38:00", + "updated_by": null + }, + { + "account_id": 12345678, + "created_at": "2023-05-05 09:38:00", + "created_by": 0, + "description": "Описание статуса \"Первичный контакт\" для кандидата", + "id": 491, + "level": "candidate", + "pipeline_id": 3177727, + "status_id": 32392159, + "updated_at": "2023-05-05 09:38:00", + "updated_by": 12345 + }, + { + "account_id": 12345678, + "created_at": "2023-05-05 09:38:00", + "created_by": 123456, + "description": "Описание статуса \"Первичный контакт\" для мастера", + "id": 493, + "level": "master", + "pipeline_id": 3177727, + "status_id": 32392159, + "updated_at": "2023-05-05 09:38:00", + "updated_by": 123457 + } + ] + }, + { + "id": 32392165, + "name": "Принимают решение update", + "sort": 30, + "is_editable": true, + "pipeline_id": 3177727, + "color": "#ffcc66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/32392165" + } + }, + "descriptions": [] + }, + { + "id": 142, + "name": "Успешно реализовано update", + "sort": 10000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#CCFF66", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/142" + } + }, + "descriptions": [] + }, + { + "id": 143, + "name": "Закрыто и не реализовано update", + "sort": 11000, + "is_editable": false, + "pipeline_id": 3177727, + "color": "#D5D8DB", + "type": 0, + "account_id": 12345678, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3177727/statuses/143" + } + }, + "descriptions": [] + } + ] + } +}` + +var jsonLeadsTags = `{ + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=1&limit=50" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=2&limit=50" + } + }, + "_embedded": { + "tags": [ + { + "id": 17, + "name": "Заявка с сайта1", + "color": "EBEBEB" + }, + { + "id": 16, + "name": "Техническая поддержка1", + "color": null + } + ] + } +}` + +var jsonLeadsTags2 = `{ + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=1&limit=50" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=2&limit=50" + } + }, + "_embedded": { + "tags": [ + { + "id": 15, + "name": "Заявка с сайта update1", + "color": "EBEBEB" + }, + { + "id": 14, + "name": "Техническая поддержка update1", + "color": null + } + ] + } +}` + +var jsonContactsTags = `{ + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=1&limit=50" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=2&limit=50" + } + }, + "_embedded": { + "tags": [ + { + "id": 13, + "name": "ContactsTags11", + "color": "EBEBEB" + }, + { + "id": 12, + "name": "ContactsTags22", + "color": null + } + ] + } +}` + +var jsonContactsTags2 = `{ + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=1&limit=50" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=2&limit=50" + } + }, + "_embedded": { + "tags": [ + { + "id": 11, + "name": "ContactsTags1 update1", + "color": "EBEBEB" + }, + { + "id": 9, + "name": "ContactsTags2 update2", + "color": null + } + ] + } +}` + +var jsonCompaniesTags = `{ + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=1&limit=50" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=2&limit=50" + } + }, + "_embedded": { + "tags": [ + { + "id": 8, + "name": "CompaniesTags11", + "color": "EBEBEB" + }, + { + "id": 7, + "name": "CompaniesTags22", + "color": null + } + ] + } +}` + +var jsonCompaniesTags2 = `{ + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=1&limit=50" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=2&limit=50" + } + }, + "_embedded": { + "tags": [ + { + "id": 6, + "name": "CompaniesTags1 update1", + "color": "EBEBEB" + }, + { + "id": 5, + "name": "CompaniesTags2 update2", + "color": null + } + ] + } +}` + +var jsonCustomersTags = `{ + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=1&limit=50" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=2&limit=50" + } + }, + "_embedded": { + "tags": [ + { + "id": 4, + "name": "CustomersTags11", + "color": "EBEBEB" + }, + { + "id": 3, + "name": "CustomersTags22", + "color": null + } + ] + } +}` + +var jsonCustomersTags2 = `{ + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=1&limit=50" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/tags?filter[id][]=2707&filter[id][]=2709&page=2&limit=50" + } + }, + "_embedded": { + "tags": [ + { + "id": 2, + "name": "CustomersTags1 update1", + "color": "EBEBEB" + }, + { + "id": 1, + "name": "CustomersTags2 update2", + "color": null + } + ] + } +}` + +var jsonLeadsFields = `{ + "_total_items": 2, + "_page": 1, + "_page_count": 10, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=1" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=2" + }, + "last": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=10" + } + }, + "_embedded": { + "custom_fields": [ + { + "id": 21, + "name": "Пример текстового поля", + "sort": 504, + "type": "text", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": [ + { + "status_id": 41221, + "pipeline_id": 3142 + } + ], + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4439091/" + } + } + }, + { + "id": 16, + "name": "Пример поля с типом 'data'", + "sort": 505, + "type": "date", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": null, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4440043/" + } + } + } + ] + } +}` + +var jsonLeadsFields2 = `{ + "_total_items": 2, + "_page": 1, + "_page_count": 10, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=1" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=2" + }, + "last": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=10" + } + }, + "_embedded": { + "custom_fields": [ + { + "id": 15, + "name": "Пример текстового поля update", + "sort": 504, + "type": "text", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": [ + { + "status_id": 41221, + "pipeline_id": 3142 + } + ], + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4439091/" + } + } + }, + { + "id": 14, + "name": "Пример поля с типом 'data' update", + "sort": 505, + "type": "date", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": null, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4440043/" + } + } + } + ] + } +}` + +var jsonContactsFields = `{ + "_total_items": 2, + "_page": 1, + "_page_count": 10, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=1" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=2" + }, + "last": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=10" + } + }, + "_embedded": { + "custom_fields": [ + { + "id": 13, + "name": "ContactsFields", + "sort": 504, + "type": "text", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": [ + { + "status_id": 41221, + "pipeline_id": 3142 + } + ], + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4439091/" + } + } + }, + { + "id": 12, + "name": "ContactsFields", + "sort": 505, + "type": "date", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": null, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4440043/" + } + } + } + ] + } +}` + +var jsonContactsFields2 = `{ + "_total_items": 2, + "_page": 1, + "_page_count": 10, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=1" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=2" + }, + "last": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=10" + } + }, + "_embedded": { + "custom_fields": [ + { + "id": 11, + "name": "ContactsFields update", + "sort": 504, + "type": "text", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": [ + { + "status_id": 41221, + "pipeline_id": 3142 + } + ], + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4439091/" + } + } + }, + { + "id": 9, + "name": "ContactsFields update", + "sort": 505, + "type": "date", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": null, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4440043/" + } + } + } + ] + } +}` + +var jsonCompaniesFields = `{ + "_total_items": 2, + "_page": 1, + "_page_count": 10, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=1" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=2" + }, + "last": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=10" + } + }, + "_embedded": { + "custom_fields": [ + { + "id": 8, + "name": "CompaniesFields", + "sort": 504, + "type": "text", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": [ + { + "status_id": 41221, + "pipeline_id": 3142 + } + ], + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4439091/" + } + } + }, + { + "id": 7, + "name": "CompaniesFields", + "sort": 505, + "type": "date", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": null, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4440043/" + } + } + } + ] + } +}` + +var jsonCompaniesFields2 = `{ + "_total_items": 2, + "_page": 1, + "_page_count": 10, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=1" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=2" + }, + "last": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=10" + } + }, + "_embedded": { + "custom_fields": [ + { + "id": 6, + "name": "CompaniesFields update", + "sort": 504, + "type": "text", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": [ + { + "status_id": 41221, + "pipeline_id": 3142 + } + ], + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4439091/" + } + } + }, + { + "id": 5, + "name": "CompaniesFields update", + "sort": 505, + "type": "date", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": null, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4440043/" + } + } + } + ] + } +}` + +var jsonCustomersFields = `{ + "_total_items": 2, + "_page": 1, + "_page_count": 10, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=1" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=2" + }, + "last": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=10" + } + }, + "_embedded": { + "custom_fields": [ + { + "id": 4, + "name": "CustomersFields", + "sort": 504, + "type": "text", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": [ + { + "status_id": 41221, + "pipeline_id": 3142 + } + ], + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4439091/" + } + } + }, + { + "id": 3, + "name": "CustomersFields", + "sort": 505, + "type": "date", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": null, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4440043/" + } + } + } + ] + } +}` + +var jsonCustomersFields2 = `{ + "_total_items": 2, + "_page": 1, + "_page_count": 10, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=1" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=2" + }, + "last": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields?limit=2&page=10" + } + }, + "_embedded": { + "custom_fields": [ + { + "id": 2, + "name": "CustomersFields update", + "sort": 504, + "type": "text", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": [ + { + "status_id": 41221, + "pipeline_id": 3142 + } + ], + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4439091/" + } + } + }, + { + "id": 1, + "name": "CustomersFields update", + "sort": 505, + "type": "date", + "is_predefined": false, + "settings": null, + "remind": null, + "is_api_only": false, + "group_id": null, + "enums": null, + "required_statuses": null, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/custom_fields/4440043/" + } + } + } + ] + } +}` diff --git a/tests/tools/construct_test.go b/tests/tools/construct_test.go new file mode 100644 index 0000000..5e4949f --- /dev/null +++ b/tests/tools/construct_test.go @@ -0,0 +1,25 @@ +package tools + +import ( + "amocrm/internal/tools" + "fmt" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "testing" +) + +func Test_ConstructAmoTags(t *testing.T) { + color := "Red" + currentTags := []model.Tag{ + {ID: 1, Name: "First", Amoid: 111, Color: &color}, + {ID: 2, Name: "Iron Man", Amoid: 222, Color: nil}, + {ID: 3, Name: "Lionel Messi", Amoid: 333, Color: nil}, + } + ruleTags := model.TagsToAdd{ + Lead: []int64{111}, + Contact: []int64{222, 3333}, + Company: []int64{}, + Customer: []int64{}, + } + + fmt.Println(tools.ConstructAmoTags(currentTags, ruleTags)) +} diff --git a/tools/migrate b/tools/migrate new file mode 100755 index 0000000..573af5c Binary files /dev/null and b/tools/migrate differ