diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ffb511 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# 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/ +squiz +.idea/ +gen +worker/worker +storer/storer +answerer/answerer \ No newline at end of file diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..2064713 --- /dev/null +++ b/app/app.go @@ -0,0 +1,158 @@ +package app + +import ( + "context" + "errors" + "fmt" + "github.com/gofiber/fiber/v2" + "github.com/skeris/appInit" + "github.com/themakers/hlog" + "go.uber.org/zap" + "penahub.gitlab.yandexcloud.net/backend/penahub_common/privilege" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/healthchecks" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/core.git/clients/auth" + "penahub.gitlab.yandexcloud.net/backend/quiz/core.git/middleware" + "penahub.gitlab.yandexcloud.net/backend/quiz/core.git/service" +) + +type App struct { + logger *zap.Logger + err chan error +} + +func (a App) GetLogger() *zap.Logger { + return a.logger +} + +func (a App) GetErr() chan error { + return a.err +} + +var ( + errInvalidOptions = errors.New("invalid options") +) + +var zapOptions = []zap.Option{ + zap.AddCaller(), + zap.AddCallerSkip(2), + zap.AddStacktrace(zap.ErrorLevel), +} + +var _ appInit.CommonApp = (*App)(nil) + +type Options struct { + LoggerProdMode bool `env:"IS_PROD_LOG" default:"false"` + IsProd bool `env:"IS_PROD" default:"false"` + NumberPort string `env:"PORT" default:"1488"` + CrtFile string `env:"CRT" default:"server.crt"` + KeyFile string `env:"KEY" default:"server.key"` + PostgresCredentials string `env:"PG_CRED" default:"host=localhost port=5432 user=squiz password=Redalert2 dbname=squiz sslmode=disable"` + HubAdminUrl string `env:"HUB_ADMIN_URL"` + ServiceName string `env:"SERVICE_NAME" default:"squiz"` + AuthServiceURL string `env:"AUTH_URL"` +} + +func New(ctx context.Context, opts interface{}, ver appInit.Version) (appInit.CommonApp, error) { + var ( + err, workerErr error + zapLogger *zap.Logger + errChan = make(chan error) + options Options + ok bool + ) + + if options, ok = opts.(Options); !ok { + return App{}, errInvalidOptions + } + + if options.LoggerProdMode { + zapLogger, err = zap.NewProduction(zapOptions...) + if err != nil { + return nil, err + } + } else { + zapLogger, err = zap.NewDevelopment(zapOptions...) + if err != nil { + return nil, err + } + } + + zapLogger = zapLogger.With( + zap.String("SvcCommit", ver.Commit), + zap.String("SvcVersion", ver.Release), + zap.String("SvcBuildTime", ver.BuildTime), + ) + + logger := hlog.New(zapLogger) + logger.Emit(InfoSvcStarted{}) + + authClient := auth.NewAuthClient(options.AuthServiceURL) + + pgdal, err := dal.New(ctx, options.PostgresCredentials, authClient) + if err != nil { + fmt.Println("NEW", err) + return nil, err + } + + if err := pgdal.Init(); err != nil { + fmt.Println("INIT", err) + return nil, err + } + + clientData := privilege.Client{ + URL: options.HubAdminUrl, + ServiceName: options.ServiceName, + Privileges: model.Privileges, + } + fiberClient := &fiber.Client{} + privilegeController := privilege.NewPrivilege(clientData, fiberClient) + err = privilegeController.PublishPrivileges() + if err != nil { + logger.Module("Failed to publish privileges") + } + + app := fiber.New() + app.Use(middleware.JWTAuth()) + app.Get("/liveness", healthchecks.Liveness) + app.Get("/readiness", healthchecks.Readiness(&workerErr)) //todo parametrized readiness. should discuss ready reason + + svc := service.New(pgdal) + svc.Register(app) + + logger.Emit(InfoSvcReady{}) + + go func() { + defer func() { + if pgdal != nil { + pgdal.Close() + } + err := app.Shutdown() + logger.Emit(InfoSvcShutdown{Signal: err.Error()}) + }() + + if options.IsProd { + if err := app.ListenTLS(fmt.Sprintf(":%s", options.NumberPort), options.CrtFile, options.KeyFile); err != nil { + logger.Emit(ErrorCanNotServe{ + Err: err, + }) + errChan <- err + } + } else { + if err := app.Listen(fmt.Sprintf(":%s", options.NumberPort)); err != nil { + logger.Emit(ErrorCanNotServe{ + Err: err, + }) + errChan <- err + } + } + + errChan <- nil + }() + // todo implement helper func for service app type. such as server preparing, logger preparing, healthchecks and etc. + return &App{ + logger: zapLogger, + err: errChan, + }, err +} diff --git a/app/logrecords.go b/app/logrecords.go new file mode 100644 index 0000000..0dbaf96 --- /dev/null +++ b/app/logrecords.go @@ -0,0 +1,10 @@ +package app + +type InfoSvcStarted struct{} +type InfoSvcReady struct{} +type InfoSvcShutdown struct { + Signal string +} +type ErrorCanNotServe struct { + Err error +} diff --git a/clients/auth/auth.go b/clients/auth/auth.go new file mode 100644 index 0000000..aea0018 --- /dev/null +++ b/clients/auth/auth.go @@ -0,0 +1,44 @@ +package auth + +import ( + "fmt" + "github.com/gofiber/fiber/v2" + "log" +) + +type AuthClient struct { + URL string +} + +type User struct { + Email string `json:"login"` +} + +func NewAuthClient(url string) *AuthClient { + if url == "" { + log.Panicln("url is nil on ") + } + + return &AuthClient{ + URL: url, + } +} + +func (client *AuthClient) GetUserEmail(userID string) (string, error) { + fiberClient := fiber.Client{} + + userURL := fmt.Sprintf("%s/%s", client.URL, userID) + + var user User + status, resp, errs := fiberClient.Get(userURL).Struct(&user) + + if len(errs) > 0 { + return "", errs[0] + } + + if status != fiber.StatusOK { + return "", fmt.Errorf("unexpected status code: %d, user: %s, reesponse: %s", status, userID, string(resp)) + } + + return user.Email, nil +} 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..da5ff20 --- /dev/null +++ b/deployments/main/docker-compose.yaml @@ -0,0 +1,77 @@ +services: + core: + hostname: squiz-core + container_name: squiz-core + image: $CI_REGISTRY_IMAGE/main-core:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + HUB_ADMIN_URL: 'http://10.8.0.8:59303' + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PORT: 1488 + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PG_CRED: 'host=10.8.0.9 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + AUTH_URL: 'http://10.8.0.8:59300/user' + ports: + - 10.8.0.9:1488:1488 + + storer: + hostname: squiz-storer + container_name: squiz-storer + image: $CI_REGISTRY_IMAGE/main-storer:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PORT: 1489 + MINIO_EP: 'storage.yandexcloud.net' + MINIO_AK: 'YCAJEOcqqTHpiwL4qFwLfHPNA' + MINIO_SK: 'YCNIAIat0XqdDzycWsYKX3OU7mPor6S0WmMoG4Ry' + PG_CRED: 'host=10.8.0.9 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + ports: + - 10.8.0.9:1489:1489 + worker: + hostname: squiz-worker + container_name: squiz-worker + image: $CI_REGISTRY_IMAGE/main-worker:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PG_CRED: 'host=10.8.0.9 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + KAFKA_BROKER: '10.8.0.8:9092' + KAFKA_TOPIC: 'tariffs' + QUIZ_ID: quizCnt + AMOUNT: 10 + UNLIM_ID: quizUnlimTime + REDIS_HOST: '10.8.0.9:6379' + REDIS_PASSWORD: 'Redalert2' + REDIS_DB: 2 + SMTP_API_URL: 'https://api.smtp.bz/v1/smtp/send' + SMTP_HOST: 'connect.smtp.bz' + SMTP_PORT: '587' + SMTP_UNAME: 'team@pena.digital' + SMTP_PASS: 'AyMfwqA9LkQH' + SMTP_API_KEY: '8tv2xcsfCMBX3TCQxzgeeEwAEYyQrPUp0ggw' + SMTP_SENDER: 'recovery@noreply.pena.digital' + CUSTOMER_SERVICE_ADDRESS: 'http://10.8.0.8:8065/' + answerer: + hostname: squiz-answerer + container_name: squiz-answerer + image: $CI_REGISTRY_IMAGE/main-answerer:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PORT: 1490 + MINIO_EP: 'storage.yandexcloud.net' + MINIO_AK: 'YCAJEOcqqTHpiwL4qFwLfHPNA' + MINIO_SK: 'YCNIAIat0XqdDzycWsYKX3OU7mPor6S0WmMoG4Ry' + PG_CRED: 'host=10.8.0.9 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + REDIS_HOST: '10.8.0.9:6379' + REDIS_PASSWORD: 'Redalert2' + REDIS_DB: 2 + ports: + - 10.8.0.9:1490:1490 diff --git a/deployments/main/staging/docker-compose.yaml b/deployments/main/staging/docker-compose.yaml new file mode 100644 index 0000000..4aaed26 --- /dev/null +++ b/deployments/main/staging/docker-compose.yaml @@ -0,0 +1,77 @@ +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 + + storer: + hostname: squiz-storer + container_name: squiz-storer + image: $CI_REGISTRY_IMAGE/storer:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PORT: 1489 + MINIO_EP: 'storage.yandexcloud.net' + MINIO_AK: 'YCAJEOcqqTHpiwL4qFwLfHPNA' + MINIO_SK: 'YCNIAIat0XqdDzycWsYKX3OU7mPor6S0WmMoG4Ry' + PG_CRED: 'host=10.6.0.23 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + ports: + - 1489:1489 + worker: + hostname: squiz-worker + container_name: squiz-worker + image: $CI_REGISTRY_IMAGE/worker:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PG_CRED: 'host=10.6.0.23 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + KAFKA_BROKER: '10.6.0.11:9092' + KAFKA_TOPIC: 'tariffs' + QUIZ_ID: quizCnt + AMOUNT: 10 + UNLIM_ID: quizUnlimTime + REDIS_HOST: '10.6.0.23:6379' + REDIS_PASSWORD: 'Redalert2' + REDIS_DB: 2 + SMTP_HOST: 'connect.mailclient.bz' + SMTP_PORT: '587' + SMTP_SENDER: 'noreply@mailing.pena.digital' + SMTP_IDENTITY: '' + SMTP_USERNAME: 'kotilion.95@gmail.com' + SMTP_PASSWORD: 'vWwbCSg4bf0p' + SMTP_API_KEY: 'P0YsjUB137upXrr1NiJefHmXVKW1hmBWlpev' + CUSTOMER_SERVICE_ADDRESS: 'http://10.6.0.11:8065/' + answerer: + hostname: squiz-answerer + container_name: squiz-answerer + image: $CI_REGISTRY_IMAGE/answerer:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PORT: 1490 + MINIO_EP: 'storage.yandexcloud.net' + MINIO_AK: 'YCAJEOcqqTHpiwL4qFwLfHPNA' + MINIO_SK: 'YCNIAIat0XqdDzycWsYKX3OU7mPor6S0WmMoG4Ry' + PG_CRED: 'host=10.6.0.23 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + REDIS_HOST: '10.6.0.23:6379' + REDIS_PASSWORD: 'Redalert2' + REDIS_DB: 2 + ports: + - 1490:1490 diff --git a/deployments/staging/docker-compose.yaml b/deployments/staging/docker-compose.yaml new file mode 100644 index 0000000..76c10ae --- /dev/null +++ b/deployments/staging/docker-compose.yaml @@ -0,0 +1,77 @@ +services: + core: + hostname: squiz-core + container_name: squiz-core + image: $CI_REGISTRY_IMAGE/staging-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 + + storer: + hostname: squiz-storer + container_name: squiz-storer + image: $CI_REGISTRY_IMAGE/staging-storer:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PORT: 1489 + MINIO_EP: 'storage.yandexcloud.net' + MINIO_AK: 'YCAJEOcqqTHpiwL4qFwLfHPNA' + MINIO_SK: 'YCNIAIat0XqdDzycWsYKX3OU7mPor6S0WmMoG4Ry' + PG_CRED: 'host=10.6.0.23 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + ports: + - 1489:1489 + worker: + hostname: squiz-worker + container_name: squiz-worker + image: $CI_REGISTRY_IMAGE/staging-worker:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PG_CRED: 'host=10.6.0.23 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + KAFKA_BROKER: '10.6.0.11:9092' + KAFKA_TOPIC: 'tariffs' + QUIZ_ID: quizCnt + AMOUNT: 10 + UNLIM_ID: quizUnlimTime + REDIS_HOST: '10.6.0.23:6379' + REDIS_PASSWORD: 'Redalert2' + REDIS_DB: 2 + SMTP_HOST: 'connect.mailclient.bz' + SMTP_PORT: '587' + SMTP_SENDER: 'noreply@mailing.pena.digital' + SMTP_IDENTITY: '' + SMTP_USERNAME: 'kotilion.95@gmail.com' + SMTP_PASSWORD: 'vWwbCSg4bf0p' + SMTP_API_KEY: 'P0YsjUB137upXrr1NiJefHmXVKW1hmBWlpev' + CUSTOMER_SERVICE_ADDRESS: 'http://10.6.0.11:8065/' + answerer: + hostname: squiz-answerer + container_name: squiz-answerer + image: $CI_REGISTRY_IMAGE/staging-answerer:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID + tty: true + environment: + IS_PROD_LOG: 'false' + IS_PROD: 'false' + PUBLIC_ACCESS_SECRET_KEY: $JWT_PUBLIC_KEY + PORT: 1490 + MINIO_EP: 'storage.yandexcloud.net' + MINIO_AK: 'YCAJEOcqqTHpiwL4qFwLfHPNA' + MINIO_SK: 'YCNIAIat0XqdDzycWsYKX3OU7mPor6S0WmMoG4Ry' + PG_CRED: 'host=10.6.0.23 port=5433 user=squiz password=Redalert2 dbname=squiz sslmode=disable' + REDIS_HOST: '10.6.0.23:6379' + REDIS_PASSWORD: 'Redalert2' + REDIS_DB: 2 + ports: + - 1490:1490 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/deployments/testmigrate/docker-compose.yaml b/deployments/testmigrate/docker-compose.yaml new file mode 100644 index 0000000..dd98264 --- /dev/null +++ b/deployments/testmigrate/docker-compose.yaml @@ -0,0 +1,23 @@ +version: '3' +services: + test-postgres: + image: postgres + environment: + POSTGRES_PASSWORD: Redalert2 + POSTGRES_USER: squiz + POSTGRES_DB: squiz + ports: + - 35432:5432 + networks: + - penatest + healthcheck: + test: pg_isready -U squiz + interval: 2s + timeout: 2s + retries: 10 + +networks: + penatest: + +# просто чтоб тестануть мигрировала ли бд +# в app/app.go pgdal, err := dal.New(ctx, "host=localhost port=35432 user=squiz password=Redalert2 dbname=squiz sslmode=disable") diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a60b56d --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module penahub.gitlab.yandexcloud.net/backend/quiz/core.git + +go 1.21.4 + +require ( + github.com/gofiber/fiber/v2 v2.52.0 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/lib/pq v1.10.9 + github.com/skeris/appInit v1.0.2 + github.com/tealeg/xlsx v1.0.5 + github.com/themakers/hlog v0.0.0-20191205140925-235e0e4baddf + go.uber.org/zap v1.26.0 + penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240202120244-c4ef330cfe5d +) + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.0 // 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/rivo/uniseg v0.2.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/sys v0.15.0 // indirect + penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240219174347-aa1b1a89378e // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8ab11f6 --- /dev/null +++ b/go.sum @@ -0,0 +1,100 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +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/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/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/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +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.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/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/skeris/appInit v1.0.2 h1:Hr4KbXYd6kolTVq4cXGqDpgnpmaauiOiKizA1+Ep4KQ= +github.com/skeris/appInit v1.0.2/go.mod h1:4ElEeXWVGzU3dlYq/eMWJ/U5hd+LKisc1z3+ySh1XmY= +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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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/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/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +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/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240202120244-c4ef330cfe5d h1:gbaDt35HMDqOK84WYmDIlXMI7rstUcRqNttaT6Kx1do= +penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240202120244-c4ef330cfe5d/go.mod h1:lTmpjry+8evVkXWbEC+WMOELcFkRD1lFMc7J09mOndM= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240219173525-b14fbf58477f h1:4i2MBe+UZeboeWTOW37x0cAMml+ZIzXO0DIQPhm5rLo= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240219173525-b14fbf58477f/go.mod h1:rcY5DQK14XW+/kYNOujQXPf79oZE5eI74sJntAKY7ek= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240219174347-aa1b1a89378e h1:/LG1jEu8gPL6K4vLBuHpm6/0kIua+J+KnbJL1yd8CC8= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240219174347-aa1b1a89378e/go.mod h1:rcY5DQK14XW+/kYNOujQXPf79oZE5eI74sJntAKY7ek= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7ac320d --- /dev/null +++ b/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/skeris/appInit" + "penahub.gitlab.yandexcloud.net/backend/quiz/core.git/app" +) + +func main() { + appInit.Initialize(app.New, app.Options{}) +} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 0000000..a35da55 --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,80 @@ +package middleware + +import ( + "github.com/gofiber/fiber/v2" + "os" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + AccountId = "id" +) + +func JWTAuth() fiber.Handler { + return func(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + if authHeader == "" { + c.Status(fiber.StatusUnauthorized).SendString("no JWT found") + return nil + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + c.Status(fiber.StatusUnauthorized).SendString("invalid JWT Header: missing Bearer") + return nil + } + + publicKey := os.Getenv("PUBLIC_ACCESS_SECRET_KEY") + if publicKey == "" { + // TODO log + c.Status(fiber.StatusInternalServerError).SendString("public key not found") + return nil + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return jwt.ParseRSAPublicKeyFromPEM([]byte(publicKey)) + }) + if err != nil { + c.Status(fiber.StatusUnauthorized).SendString("invalid JWT") + return nil + } + + if token.Valid { + expirationTime, err := token.Claims.GetExpirationTime() + if err != nil { + c.Status(fiber.StatusUnauthorized).SendString("no expiration time in JWT") + return nil + } + if time.Now().Unix() >= expirationTime.Unix() { + c.Status(fiber.StatusUnauthorized).SendString("expired JWT") + return nil + } + } else { + c.Status(fiber.StatusUnauthorized).SendString("invalid JWT") + return nil + } + + m, ok := token.Claims.(jwt.MapClaims) + if !ok { + c.Status(fiber.StatusInternalServerError).SendString("broken token claims") + return nil + } + + id, ok := m["id"].(string) + if !ok || id == "" { + c.Status(fiber.StatusUnauthorized).SendString("missing id claim in JWT") + return nil + } + + c.Context().SetUserValue(AccountId, id) + return c.Next() + } +} + +func GetAccountId(c *fiber.Ctx) (string, bool) { + id, ok := c.Context().UserValue(AccountId).(string) + return id, ok +} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..e69de29 diff --git a/pkg/excel_export.go b/pkg/excel_export.go new file mode 100644 index 0000000..3f04a4c --- /dev/null +++ b/pkg/excel_export.go @@ -0,0 +1,99 @@ +package pkg + +import ( + "github.com/tealeg/xlsx" + "io" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "sort" +) + +func WriteDataToExcel(buffer io.Writer, questions []model.Question, answers []model.Answer) error { + file := xlsx.NewFile() + sheet, err := file.AddSheet("Results") + if err != nil { + return err + } + + headers := []string{"Данные респондента"} + mapQueRes := make(map[uint64]string) + + for _, q := range questions { + if !q.Deleted { + if q.Type == model.TypeResult { + mapQueRes[q.Id] = q.Title + "\n" + q.Description + } else { + headers = append(headers, q.Title) + } + } + } + + headers = append(headers, "Результат") + + // добавляем заголовки в первую строку + row := sheet.AddRow() + for _, header := range headers { + cell := row.AddCell() + cell.Value = header + } + + // мапа для хранения обычных ответов респондентов + standart := make(map[string][]model.Answer) + + // мапа для хранения данных респондентов + results := make(map[string]model.Answer) + + // заполняем мапу ответами и данными респондентов + for _, answer := range answers { + if answer.Result { + // если это результат то данные респондента берутся из контента ответа по сессии + results[answer.Session] = answer + } else { + // если это обычный ответ то добавляем его в соответствующий список ответов респондента + standart[answer.Session] = append(standart[answer.Session], answer) + } + } + // записываем данные в файл + for session, _ := range results { + response := standart[session] + row := sheet.AddRow() + row.AddCell().Value = results[session].Content // данные респондента + for _, q := range questions { + if !q.Deleted && q.Type != model.TypeResult { + sort.Slice(response, func(i, j int) bool { + return response[i].QuestionId < response[j].QuestionId + }) + index := binarySearch(response, q.Id) + if index != -1 { + row.AddCell().Value = response[index].Content + } else { + row.AddCell().Value = "-" + } + } + } + row.AddCell().Value = mapQueRes[results[session].QuestionId] + } + + // cохраняем данные в буфер + err = file.Write(buffer) + if err != nil { + return err + } + + return nil +} + +func binarySearch(answers []model.Answer, questionID uint64) int { + left := 0 + right := len(answers) - 1 + for left <= right { + mid := left + (right-left)/2 + if answers[mid].QuestionId == questionID { + return mid + } else if answers[mid].QuestionId < questionID { + left = mid + 1 + } else { + right = mid - 1 + } + } + return -1 +} diff --git a/service/account_svc.go b/service/account_svc.go new file mode 100644 index 0000000..2e71dd6 --- /dev/null +++ b/service/account_svc.go @@ -0,0 +1,187 @@ +package service + +import ( + "database/sql" + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/core.git/middleware" + "time" +) + +type CreateAccountReq struct { + UserID string `json:"userId"` +} + +type CreateAccountResp struct { + CreatedAccount model.Account `json:"created_account"` +} + +type DeleteAccountResp struct { + DeletedAccountID string `json:"account_Id"` +} + +type GetPrivilegeByUserIDReq struct { + UserID string `json:"userId"` +} + +type DeleteAccountByUserIDReq struct { + UserID string `json:"userId"` +} + +type DeleteAccountByUserIDResp struct { + DeletedAccountUserID string `json:"userId"` +} + +type GetAccountsReq struct { + Limit uint64 `json:"limit"` + Page uint64 `json:"page"` +} + +type GetAccountsResp struct { + Count uint64 `json:"count"` + Items []model.Account `json:"items"` +} + +// getCurrentAccount обработчик для получения текущего аккаунта +func (s *Service) getCurrentAccount(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + account, err := s.dal.AccountRepo.GetAccountByID(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + //TODO: fix this later + if account.ID == "" { + return ctx.Status(fiber.StatusNotFound).SendString("no account") + } + + return ctx.Status(fiber.StatusOK).JSON(account) +} + +// createAccount обработчик для создания нового аккаунта +func (s *Service) createAccount(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + existingAccount, err := s.dal.AccountRepo.GetAccountByID(ctx.Context(), accountID) + if err != nil && err != sql.ErrNoRows { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + if existingAccount.ID != "" { + return ctx.Status(fiber.StatusConflict).SendString("user with this ID already exists") + } + + newAccount := model.Account{ + UserID: accountID, + CreatedAt: time.Now(), + Deleted: false, + Privileges: map[string]model.ShortPrivilege{ + "quizUnlimTime": { + PrivilegeID: "quizUnlimTime", + PrivilegeName: "Безлимит Опросов", + Amount: 14, + CreatedAt: time.Now(), + }, + }, + } + + if err := s.dal.AccountRepo.CreateAccount(ctx.Context(), &newAccount); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(CreateAccountResp{ + CreatedAccount: newAccount, + }) +} + +// deleteAccount обработчик для удаления текущего аккаунта +func (s *Service) deleteAccount(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + account, err := s.dal.AccountRepo.GetAccountByID(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if err := s.dal.AccountRepo.DeleteAccount(ctx.Context(), account.ID); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(DeleteAccountResp{ + DeletedAccountID: accountID, + }) +} + +// getPrivilegeByUserID обработчик для получения привилегий аккаунта по ID пользователя +func (s *Service) getPrivilegeByUserID(ctx *fiber.Ctx) error { + var req GetPrivilegeByUserIDReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + privilege, err := s.dal.AccountRepo.GetPrivilegesByAccountID(ctx.Context(), req.UserID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.Status(fiber.StatusOK).JSON(privilege) +} + +// deleteAccountByUserID обработчик для удаления аккаунта по ID пользователя +func (s *Service) deleteAccountByUserID(ctx *fiber.Ctx) error { + var req DeleteAccountByUserIDReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + existingAccount, err := s.dal.AccountRepo.GetAccountByID(ctx.Context(), req.UserID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if existingAccount.ID == "" { + return ctx.Status(fiber.StatusInternalServerError).SendString("user with this ID not found") + } + + if err := s.dal.AccountRepo.DeleteAccount(ctx.Context(), existingAccount.ID); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(DeleteAccountByUserIDResp{ + DeletedAccountUserID: req.UserID, + }) +} + +// getAccounts обработчик для получения списка аккаунтов с пагинацией +func (s *Service) getAccounts(ctx *fiber.Ctx) error { + var req GetAccountsReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + _, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + accounts, totalCount, err := s.dal.AccountRepo.GetAccounts(ctx.Context(), req.Limit, req.Page) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + response := GetAccountsResp{ + Count: totalCount, + Items: accounts, + } + + return ctx.Status(fiber.StatusOK).JSON(response) +} diff --git a/service/question_svc.go b/service/question_svc.go new file mode 100644 index 0000000..de73584 --- /dev/null +++ b/service/question_svc.go @@ -0,0 +1,294 @@ +package service + +import ( + "github.com/gofiber/fiber/v2" + "github.com/lib/pq" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "unicode/utf8" +) + +// QuestionCreateReq request structure for creating Question +type QuestionCreateReq struct { + QuizId uint64 `json:"quiz_id"` // relation to quiz + + Title string `json:"title"` // title of question + Description string `json:"description"` // additional content in question such as pics, html markup or plain text + Type string `json:"type"` // button/select/file/checkbox/text + Required bool `json:"required"` // set true if question must be answered for valid quiz passing + Page int `json:"page"` // set page of question + Content string `json:"content"` // json serialized config of question +} + +// CreateQuestion service handler for creating question for quiz +func (s *Service) CreateQuestion(ctx *fiber.Ctx) error { + var req QuestionCreateReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + if utf8.RuneCountInString(req.Title) >= 512 { + return ctx.Status(fiber.StatusUnprocessableEntity).SendString("title field should have less then 512 chars") + } + + if req.Type != model.TypeText && + req.Type != model.TypeVariant && + req.Type != model.TypeImages && + req.Type != model.TypeSelect && + req.Type != model.TypeVarImages && + req.Type != model.TypeEmoji && + req.Type != model.TypeDate && + req.Type != model.TypeNumber && + req.Type != model.TypePage && + req.Type != model.TypeRating && + req.Type != model.TypeResult && + req.Type != model.TypeFile { + return ctx.Status(fiber.StatusNotAcceptable).SendString("type must be only test,button,file,checkbox,select, none") + } + + result := model.Question{ + QuizId: req.QuizId, + Title: req.Title, + Description: req.Description, + Type: req.Type, + Required: req.Required, + Deleted: false, + Page: req.Page, + Content: req.Content, + } + if err := s.dal.QuestionRepo.CreateQuestion(ctx.Context(), &result); err != nil { + if e, ok := err.(pq.Error); ok { + if e.Constraint == "quiz_relation" { + return ctx.Status(fiber.StatusFailedDependency).SendString(e.Error()) + } + } + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.Status(fiber.StatusOK).JSON(result) +} + +// GetQuestionListReq request structure for get question page +type GetQuestionListReq struct { + Limit uint64 `json:"limit"` // page size + Page uint64 `json:"page"` // page number + From int64 `json:"from"` // start of time period + To int64 `json:"to"` // end of time period + QuizId uint64 `json:"quiz_id"` // relation to quiz + + Search string `json:"search"` // search string to search in files + Type string `json:"type"` // type of questions. check types in model + Deleted bool `json:"deleted"` // true to get only deleted questions + Required bool `json:"required"` +} + +// GetQuestionListResp response to get page questions with count of all filtered items +type GetQuestionListResp struct { + Count uint64 `json:"count"` + Items []model.Question `json:"items"` +} + +// GetQuestionList handler for paginated list question +func (s *Service) GetQuestionList(ctx *fiber.Ctx) error { + var req GetQuestionListReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + if req.Type != "" && + req.Type != model.TypeText && + req.Type != model.TypeVariant && + req.Type != model.TypeImages && + req.Type != model.TypeSelect && + req.Type != model.TypeVarImages && + req.Type != model.TypeEmoji && + req.Type != model.TypeDate && + req.Type != model.TypeNumber && + req.Type != model.TypePage && + req.Type != model.TypeRating && + req.Type != model.TypeResult && + req.Type != model.TypeFile { + return ctx.Status(fiber.StatusNotAcceptable).SendString("inappropriate type, allowed only '', " + + "'test','none','file', 'button','select','checkbox'") + } + + res, cnt, err := s.dal.QuestionRepo.GetQuestionList(ctx.Context(), + req.Limit, + req.Page*req.Limit, + uint64(req.From), + uint64(req.To), + req.QuizId, + req.Deleted, + req.Required, + req.Search, + req.Type, + ) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(GetQuestionListResp{ + Items: res, + Count: cnt, + }) +} + +// UpdateQuestionReq struct for request to update question +type UpdateQuestionReq struct { + Id uint64 `json:"id"` + Title string `json:"title"` + Description string `json:"desc"` + Type string `json:"type"` + Required bool `json:"required"` + Content string `json:"content"` + Page int `json:"page"` +} + +// UpdateResp id you change question that you need only new question id +type UpdateResp struct { + Updated uint64 `json:"updated"` +} + +// UpdateQuestion handler for update question +func (s *Service) UpdateQuestion(ctx *fiber.Ctx) error { + var req UpdateQuestionReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + if req.Id == 0 { + return ctx.Status(fiber.StatusFailedDependency).SendString("need id of question for update") + } + + if utf8.RuneCountInString(req.Title) >= 512 { + return ctx.Status(fiber.StatusUnprocessableEntity).SendString("title field should have less then 512 chars") + } + + if req.Type != model.TypeText && + req.Type != model.TypeVariant && + req.Type != model.TypeImages && + req.Type != model.TypeSelect && + req.Type != model.TypeVarImages && + req.Type != model.TypeEmoji && + req.Type != model.TypeDate && + req.Type != model.TypeNumber && + req.Type != model.TypePage && + req.Type != model.TypeRating && + req.Type != model.TypeFile && + req.Type != model.TypeResult && + req.Type != "" { + return ctx.Status(fiber.StatusNotAcceptable).SendString("type must be only test,button,file,checkbox,select, none or empty string") + } + + question, err := s.dal.QuestionRepo.MoveToHistoryQuestion(ctx.Context(), req.Id) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + question.ParentIds = append(question.ParentIds, int32(question.Id)) + question.Id = req.Id + question.Version += 1 + + if req.Title != "" { + question.Title = req.Title + } + + if req.Description != "" { + question.Description = req.Description + } + + if req.Page != 0 { + question.Page = req.Page + } + + if req.Type != "" { + question.Type = req.Type + } + + if req.Required != question.Required { + question.Required = req.Required + } + + if req.Content != question.Content { + question.Content = req.Content + } + + if err := s.dal.QuestionRepo.UpdateQuestion(ctx.Context(), question); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(UpdateResp{ + Updated: question.Id, + }) +} + +// CopyQuestionReq request struct for copy or duplicate question +type CopyQuestionReq struct { + Id uint64 `json:"id"` + QuizId uint64 `json:"quiz_id"` +} + +// CopyQuestion handler for copy question +func (s *Service) CopyQuestion(ctx *fiber.Ctx) error { + var req CopyQuestionReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + if req.Id == 0 { + return ctx.Status(fiber.StatusFailedDependency).SendString("no id provided") + } + + question, err := s.dal.QuestionRepo.CopyQuestion(ctx.Context(), req.Id, req.QuizId) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(UpdateResp{ + Updated: question.Id, + }) +} + +// GetQuestionHistoryReq struct of get history request +type GetQuestionHistoryReq struct { + Id uint64 `json:"id"` + Limit uint64 `json:"l"` + Page uint64 `json:"p"` +} + +// GetQuestionHistory handler for history of quiz +func (s *Service) GetQuestionHistory(ctx *fiber.Ctx) error { + var req GetQuizHistoryReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + if req.Id == 0 { + return ctx.Status(fiber.StatusFailedDependency).SendString("no id provided") + } + + history, err := s.dal.QuestionRepo.QuestionHistory(ctx.Context(), req.Id, req.Limit, req.Page*req.Limit) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.Status(fiber.StatusOK).JSON(history) +} + +// DeleteQuestion handler for fake delete question +func (s *Service) DeleteQuestion(ctx *fiber.Ctx) error { + var req DeactivateReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + if req.Id == 0 { + return ctx.Status(fiber.StatusFailedDependency).SendString("id for deleting question is required") + } + + deleted, err := s.dal.QuestionRepo.DeleteQuestion(ctx.Context(), req.Id) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(DeactivateResp{ + Deactivated: deleted.Id, + }) +} diff --git a/service/quiz_svc.go b/service/quiz_svc.go new file mode 100644 index 0000000..02826ea --- /dev/null +++ b/service/quiz_svc.go @@ -0,0 +1,432 @@ +package service + +import ( + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/quiz" + "penahub.gitlab.yandexcloud.net/backend/quiz/core.git/middleware" + "time" + "unicode/utf8" +) + +type CreateQuizReq struct { + Fingerprinting bool `json:"fingerprinting"` // true if you need to store device id + Repeatable bool `json:"repeatable"` // make it true for allow more than one quiz checkouting + NotePrevented bool `json:"note_prevented"` // note answers even if the quiz was aborted + MailNotifications bool `json:"mail_notifications"` // set true if you want get an email with every quiz passing + UniqueAnswers bool `json:"unique_answers"` // set true if we you mention only last quiz passing + + Name string `json:"name"` // max 280 chars + Description string `json:"description"` + Config string `json:"config"` // serialize json with config for page rules. fill it up only if implement one form scenario + Status string `json:"status"` // status of quiz as enum. see Status const. fill it up only if implement one form scenario + Limit uint64 `json:"limit"` // max count of quiz passing + DueTo uint64 `json:"due_to"` // time when quiz is end + + QuestionCnt uint64 `json:"question_cnt"` // for creating at one request + + TimeOfPassing uint64 `json:"time_of_passing"` // amount of seconds for give all appropriate answers for quiz + Pausable bool `json:"pausable"` // true allows to pause the quiz taking + + Super bool `json:"super"` // set true if you want to create group + GroupId uint64 `json:"group_id"` // if you create quiz in group provide there the id of super quiz +} + +// CreateQuiz handler for quiz creating request +func (s *Service) CreateQuiz(ctx *fiber.Ctx) error { + var req CreateQuizReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + accountId, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + // check that we can store name + if utf8.RuneCountInString(req.Name) >= 280 { + return ctx.Status(fiber.StatusUnprocessableEntity).SendString("name field should have less then 280 chars") + } + + // status should be empty or equal one of status enum strings + // I mean not draft, template, stop, start statuses + if req.Status != "" && + req.Status != model.StatusDraft && + req.Status != model.StatusTemplate && + req.Status != model.StatusStop && + req.Status != model.StatusStart { + return ctx.Status(fiber.StatusNotAcceptable).SendString("status on creating must be only draft,template,stop,start") + } + + // DueTo should be bigger then now + if req.DueTo != 0 && req.DueTo <= uint64(time.Now().Unix()) { + return ctx.Status(fiber.StatusNotAcceptable).SendString("due to time must be lesser then now") + } + + // you can pause quiz only if it has deadline for passing + if req.Pausable && req.TimeOfPassing == 0 { + return ctx.Status(fiber.StatusConflict).SendString("you can pause quiz only if it has deadline for passing") + } + + record := model.Quiz{ + AccountId: accountId, + Fingerprinting: req.Fingerprinting, + Repeatable: req.Repeatable, + NotePrevented: req.NotePrevented, + MailNotifications: req.MailNotifications, + UniqueAnswers: req.UniqueAnswers, + Name: req.Name, + Description: req.Description, + Config: req.Config, + Status: req.Status, + Limit: req.Limit, + DueTo: req.DueTo, + TimeOfPassing: req.TimeOfPassing, + Pausable: req.Pausable, + QuestionsCount: req.QuestionCnt, + ParentIds: []int32{}, + Super: req.Super, + GroupId: req.GroupId, + } + + if err := s.dal.QuizRepo.CreateQuiz(ctx.Context(), &record); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.Status(fiber.StatusCreated).JSON(record) +} + +// GetQuizListReq request struct for paginated quiz table +type GetQuizListReq struct { + Limit uint64 `json:"limit"` + Page uint64 `json:"page"` + From int64 `json:"from"` + To int64 `json:"to"` + + Search string `json:"search"` + Status string `json:"status"` + Deleted bool `json:"deleted"` + Archived bool `json:"archived"` + Super bool `json:"super"` + GroupId uint64 `json:"group_id"` +} + +type GetQuizListResp struct { + Count uint64 `json:"count"` + Items []model.Quiz `json:"items"` +} + +// GetQuizList handler for paginated list quiz +func (s *Service) GetQuizList(ctx *fiber.Ctx) error { + var req GetQuizListReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + accountId, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + if req.Status != "" && + req.Status != model.StatusStop && + req.Status != model.StatusStart && + req.Status != model.StatusDraft && + req.Status != model.StatusTemplate && + req.Status != model.StatusTimeout && + req.Status != model.StatusOffLimit { + return ctx.Status(fiber.StatusNotAcceptable).SendString("inappropriate status, allowed only '', " + + "'stop','start','draft', 'template','timeout','offlimit'") + } + + res, cnt, err := s.dal.QuizRepo.GetQuizList(ctx.Context(), + quiz.GetQuizListDeps{ + Limit: req.Limit, + Offset: req.Limit * req.Page, + From: uint64(req.From), + To: uint64(req.To), + Group: req.GroupId, + Deleted: req.Deleted, + Archived: req.Archived, + Super: req.Super, + Search: req.Search, + Status: req.Status, + AccountId: accountId, + }) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(GetQuizListResp{ + Items: res, + Count: cnt, + }) +} + +type UpdateQuizReq struct { + Id uint64 `json:"id"` + Fingerprinting bool `json:"fp"` + Repeatable bool `json:"rep"` + NotePrevented bool `json:"note_prevented"` + MailNotifications bool `json:"mailing"` + UniqueAnswers bool `json:"uniq"` + Name string `json:"name"` + Description string `json:"desc"` + Config string `json:"conf"` + Status string `json:"status"` + Limit uint64 `json:"limit"` + DueTo uint64 `json:"due_to"` + TimeOfPassing uint64 `json:"time_of_passing"` + Pausable bool `json:"pausable"` + QuestionCnt uint64 `json:"question_cnt"` // for creating at one request + Super bool `json:"super"` + GroupId uint64 `json:"group_id"` +} + +func (s *Service) UpdateQuiz(ctx *fiber.Ctx) error { + var req UpdateQuizReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + accountId, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + if req.Id == 0 { + return ctx.Status(fiber.StatusFailedDependency).SendString("need id of question for update") + } + + if utf8.RuneCountInString(req.Name) >= 280 { + return ctx.Status(fiber.StatusUnprocessableEntity).SendString("name field should have less then 280 chars") + } + + // status should be empty or equal one of status enum strings + // I mean not draft, template, stop, start statuses + if req.Status != "" && + req.Status != model.StatusDraft && + req.Status != model.StatusTemplate && + req.Status != model.StatusStop && + req.Status != model.StatusStart { + return ctx.Status(fiber.StatusNotAcceptable).SendString("status on creating must be only draft,template,stop,start") + } + + // DueTo should be bigger then now + if req.DueTo != 0 && req.DueTo <= uint64(time.Now().Unix()) { + return ctx.Status(fiber.StatusNotAcceptable).SendString("due to time must be lesser then now") + } + + // you can pause quiz only if it has deadline for passing + if req.Pausable && req.TimeOfPassing == 0 { + return ctx.Status(fiber.StatusConflict).SendString("you can pause quiz only if it has deadline for passing") + } + + quiz, err := s.dal.QuizRepo.MoveToHistoryQuiz(ctx.Context(), req.Id, accountId) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + quiz.ParentIds = append(quiz.ParentIds, int32(quiz.Id)) + quiz.Id = req.Id + quiz.Version += 1 + + if req.Fingerprinting != quiz.Fingerprinting { + quiz.Fingerprinting = req.Fingerprinting + } + + if req.Repeatable != quiz.Repeatable { + quiz.Repeatable = req.Repeatable + } + + if req.MailNotifications != quiz.MailNotifications { + quiz.MailNotifications = req.MailNotifications + } + + if req.NotePrevented != quiz.NotePrevented { + quiz.NotePrevented = req.NotePrevented + } + + if req.UniqueAnswers != quiz.UniqueAnswers { + quiz.UniqueAnswers = req.UniqueAnswers + } + + if req.Pausable != quiz.Pausable { + quiz.Pausable = req.Pausable + } + + if req.Name != "" && req.Name != quiz.Name { + quiz.Name = req.Name + } + + if req.Description != "" && req.Description != quiz.Description { + quiz.Description = req.Description + } + + if req.Status != "" && req.Status != quiz.Status { + quiz.Status = req.Status + } + + if req.TimeOfPassing != quiz.TimeOfPassing { + quiz.TimeOfPassing = req.TimeOfPassing + } + + if req.DueTo != quiz.DueTo { + quiz.DueTo = req.DueTo + } + + if req.Limit != quiz.Limit { + quiz.Limit = req.Limit + } + + if req.Config != "" && req.Config != quiz.Config { + quiz.Config = req.Config + } + + if req.Super != quiz.Super { + quiz.Super = req.Super + } + + if req.GroupId != quiz.GroupId { + quiz.GroupId = req.GroupId + } + + quiz.QuestionsCount = req.QuestionCnt + + quiz.ParentIds = append(quiz.ParentIds, int32(quiz.Id)) + + if err := s.dal.QuizRepo.UpdateQuiz(ctx.Context(), accountId, quiz); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(UpdateResp{ + Updated: quiz.Id, + }) +} + +// CopyQuizReq request struct for copy quiz +type CopyQuizReq struct { + Id uint64 `json:"id"` +} + +// CopyQuiz request handler for copy quiz +func (s *Service) CopyQuiz(ctx *fiber.Ctx) error { + var req CopyQuizReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + accountId, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + if req.Id == 0 { + return ctx.Status(fiber.StatusFailedDependency).SendString("no id provided") + } + + quiz, err := s.dal.QuizRepo.CopyQuiz(ctx.Context(), accountId, req.Id) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(UpdateResp{ + Updated: quiz.Id, + }) +} + +// GetQuizHistoryReq struct of get history request +type GetQuizHistoryReq struct { + Id uint64 `json:"id"` + Limit uint64 `json:"l"` + Page uint64 `json:"p"` +} + +// GetQuizHistory handler for history of quiz +func (s *Service) GetQuizHistory(ctx *fiber.Ctx) error { + var req GetQuizHistoryReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + accountId, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + if req.Id == 0 { + return ctx.Status(fiber.StatusFailedDependency).SendString("no id provided") + } + + history, err := s.dal.QuizRepo.QuizHistory(ctx.Context(), quiz.QuizHistoryDeps{ + Id: req.Id, + Limit: req.Limit, + Offset: req.Page * req.Limit, + AccountId: accountId, + }) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.Status(fiber.StatusCreated).JSON(history) +} + +// DeactivateReq request structure for archiving and deleting +type DeactivateReq struct { + Id uint64 `json:"id"` +} + +type DeactivateResp struct { + Deactivated uint64 `json:"deactivated"` +} + +// DeleteQuiz handler for fake delete quiz +func (s *Service) DeleteQuiz(ctx *fiber.Ctx) error { + var req DeactivateReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + accountId, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + if req.Id == 0 { + return ctx.Status(fiber.StatusFailedDependency).SendString("id for deleting is required") + } + + deleted, err := s.dal.QuizRepo.DeleteQuiz(ctx.Context(), accountId, req.Id) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(DeactivateResp{ + Deactivated: deleted.Id, + }) +} + +// ArchiveQuiz handler for archiving quiz +func (s *Service) ArchiveQuiz(ctx *fiber.Ctx) error { + var req DeactivateReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + accountId, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + if req.Id == 0 { + return ctx.Status(fiber.StatusFailedDependency).SendString("id for archive quiz is required") + } + + archived, err := s.dal.QuizRepo.DeleteQuiz(ctx.Context(), accountId, req.Id) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.JSON(DeactivateResp{ + Deactivated: archived.Id, + }) +} diff --git a/service/result_svc.go b/service/result_svc.go new file mode 100644 index 0000000..122b998 --- /dev/null +++ b/service/result_svc.go @@ -0,0 +1,213 @@ +package service + +import ( + "bytes" + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/repository/result" + "penahub.gitlab.yandexcloud.net/backend/quiz/core.git/middleware" + "penahub.gitlab.yandexcloud.net/backend/quiz/core.git/pkg" + "strconv" + "time" +) + +type ReqExport struct { + To, From time.Time + New bool + Page uint64 + Limit uint64 +} + +type ReqExportResponse struct { + TotalCount uint64 `json:"total_count"` + Results []model.AnswerExport `json:"results"` +} + +func (s *Service) GetResultsByQuizID(ctx *fiber.Ctx) error { + payment := true // параметр для определения существования текущих привилегий юзера + + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + var req ReqExport + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + quizIDStr := ctx.Params("quizId") + quizID, err := strconv.ParseUint(quizIDStr, 10, 64) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid quiz ID format") + } + + account, err := s.dal.AccountRepo.GetAccountByID(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if len(account.Privileges) == 0 { + payment = false + } + + results, totalCount, err := s.dal.ResultRepo.GetQuizResults(ctx.Context(), quizID, result.GetQuizResDeps{ + To: req.To, + From: req.From, + New: req.New, + Page: req.Page, + Limit: req.Limit, + }, payment) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + resp := &ReqExportResponse{ + TotalCount: totalCount, + Results: results, + } + + return ctx.Status(fiber.StatusOK).JSON(resp) +} + +func (s *Service) DelResultByID(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("could not get account ID from token") + } + + resultIDStr := ctx.Params("resultId") + resultID, err := strconv.ParseUint(resultIDStr, 10, 64) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid result ID format") + } + + isOwner, err := s.dal.ResultRepo.CheckResultOwner(ctx.Context(), resultID, accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if !isOwner { + return ctx.Status(fiber.StatusUnauthorized).SendString("not the owner of the result") + } + + if err := s.dal.ResultRepo.SoftDeleteResultByID(ctx.Context(), resultID); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + ctx.Status(fiber.StatusOK) + return nil +} + +type ReqSeen struct { + Answers []int64 +} + +func (s *Service) SetStatus(ctx *fiber.Ctx) error { + var req ReqSeen + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("could not get account ID from token") + } + + answers, err := s.dal.ResultRepo.CheckResultsOwner(ctx.Context(), req.Answers, accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if len(answers) != len(req.Answers) { + return ctx.Status(fiber.StatusNotAcceptable).SendString("could not update some answers because you don't have rights") + } + + if err := s.dal.ResultRepo.UpdateAnswersStatus(ctx.Context(), accountID, answers); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + return ctx.Status(fiber.StatusOK).JSON(nil) +} + +func (s *Service) ExportResultsToCSV(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + quizIDStr := ctx.Params("quizID") + quizID, err := strconv.ParseUint(quizIDStr, 10, 64) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("invalid quiz ID") + } + + req := ReqExport{} + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("invalid request body") + } + + account, err := s.dal.AccountRepo.GetAccountByID(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if len(account.Privileges) == 0 { + return ctx.Status(fiber.StatusPaymentRequired).SendString("payment required") + } + + questions, err := s.dal.ResultRepo.GetQuestions(ctx.Context(), quizID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("failed to get questions") + } + + answers, err := s.dal.ResultRepo.GetQuizResultsCSV(ctx.Context(), quizID, result.GetQuizResDeps{ + To: req.To, + From: req.From, + New: req.New, + Page: req.Page, + Limit: req.Limit, + }) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("failed to get quiz results") + } + + buffer := new(bytes.Buffer) + + if err := pkg.WriteDataToExcel(buffer, questions, answers); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("failed to write data to Excel") + } + + ctx.Set(fiber.HeaderContentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + ctx.Set(fiber.HeaderContentDisposition, `attachment; filename="results.xlsx"`) + + return ctx.Send(buffer.Bytes()) +} + +func (s *Service) GetResultAnswers(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + resultID, err := strconv.ParseUint(ctx.Params("resultID"), 10, 64) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("invalid quiz ID") + } + + account, err := s.dal.AccountRepo.GetAccountByID(ctx.Context(), accountID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if len(account.Privileges) == 0 { + return ctx.Status(fiber.StatusPaymentRequired).SendString("payment required") + } + + answers, err := s.dal.ResultRepo.GetResultAnswers(ctx.Context(), resultID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("failed to get result answers") + } + + return ctx.JSON(answers) +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..fe812fb --- /dev/null +++ b/service/service.go @@ -0,0 +1,50 @@ +package service + +import ( + "github.com/gofiber/fiber/v2" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" +) + +// Service is an entity for http requests handling +type Service struct { + dal *dal.DAL +} + +func New(d *dal.DAL) *Service { + return &Service{dal: d} +} + +// Register is a function for add handlers of service to external multiplexer +func (s *Service) Register(app *fiber.App) { + // quiz manipulating handlers + app.Post("/quiz/create", s.CreateQuiz) + app.Post("/quiz/getList", s.GetQuizList) + app.Patch("/quiz/edit", s.UpdateQuiz) + app.Post("/quiz/copy", s.CopyQuiz) + app.Post("/quiz/history", s.GetQuizHistory) + app.Delete("/quiz/delete", s.DeleteQuiz) + app.Patch("/quiz/archive", s.ArchiveQuiz) + + // question manipulating handlers + app.Post("/question/create", s.CreateQuestion) + app.Post("/question/getList", s.GetQuestionList) + app.Patch("/question/edit", s.UpdateQuestion) + app.Post("/question/copy", s.CopyQuestion) + app.Post("/question/history", s.GetQuestionHistory) + app.Delete("/question/delete", s.DeleteQuestion) + + // account handlers + app.Get("/account/get", s.getCurrentAccount) + app.Post("/account/create", s.createAccount) + app.Delete("/account/delete", s.deleteAccount) + app.Get("/accounts", s.getAccounts) + app.Get("/privilege/:userId", s.getPrivilegeByUserID) + app.Delete("/account/:userId", s.deleteAccountByUserID) + + // result handlers + app.Post("/results/getResults/:quizId", s.GetResultsByQuizID) + app.Delete("/results/delete/:resultId", s.DelResultByID) + app.Patch("/result/seen", s.SetStatus) + app.Post("/results/:quizID/export", s.ExportResultsToCSV) + app.Get("/result/:resultID", s.GetResultAnswers) +}