├── .github └── workflows │ ├── default.yml │ ├── prod.yml │ └── stage.yml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── Dockerfile.debug ├── Makefile ├── README.md ├── cmd └── app │ └── main.go ├── configs ├── main.yml ├── prod.yml └── stage.yml ├── deploy ├── Dockerfile ├── docker-compose.yml ├── nginx │ ├── Dockerfile │ ├── docker-entrypoint.sh │ └── nginx.conf.template └── staging.yml ├── docker-compose.yml ├── docs ├── docs.go ├── swagger.json └── swagger.yaml ├── go.mod ├── go.sum ├── internal ├── app │ └── app.go ├── config │ ├── config.go │ ├── config_test.go │ └── fixtures │ │ └── main.yml ├── delivery │ └── http │ │ ├── handler.go │ │ ├── handler_test.go │ │ ├── middleware.go │ │ └── v1 │ │ ├── 60e69c1f4bb5a43711c0ee98-image.png │ │ ├── 60e69c448af06211ebfaba10-image.jpg │ │ ├── 60e69c81be7bbc1184dd21d1-image.png │ │ ├── 60e69cb986803c02115a55c5-image.jpg │ │ ├── 60e69cb986803c02115a55c5-image.png │ │ ├── 60e69cc243b3478089ed9f22-image.jpg │ │ ├── 60e69cc243b3478089ed9f22-image.png │ │ ├── admins.go │ │ ├── admins_media.go │ │ ├── admins_students.go │ │ ├── admins_surveys.go │ │ ├── admins_test.go │ │ ├── admins_upload.go │ │ ├── admins_upload_test.go │ │ ├── courses.go │ │ ├── fixtures │ │ ├── ccc.pdf │ │ ├── image.jpg │ │ ├── image.png │ │ └── large.jpeg │ │ ├── handler.go │ │ ├── middleware.go │ │ ├── offer.go │ │ ├── payment.go │ │ ├── promocodes.go │ │ ├── promocodes_test.go │ │ ├── response.go │ │ ├── settings.go │ │ ├── students.go │ │ ├── students_test.go │ │ └── users.go ├── domain │ ├── course.go │ ├── errors.go │ ├── file.go │ ├── offer.go │ ├── order.go │ ├── promocode.go │ ├── query.go │ ├── school.go │ ├── session.go │ ├── student.go │ ├── survey.go │ └── user.go ├── repository │ ├── admins_mongo.go │ ├── collections.go │ ├── courses_mongo.go │ ├── files.go │ ├── lessons_mongo.go │ ├── mocks │ │ └── mock.go │ ├── modules_mongo.go │ ├── offers_mongo.go │ ├── orders_mongo.go │ ├── packages_mongo.go │ ├── promocodes_mongo.go │ ├── repository.go │ ├── schools_mongo.go │ ├── student_lessons_mongo.go │ ├── students_mongo.go │ ├── survey_results_mongo.go │ └── users_mongo.go ├── server │ └── server.go └── service │ ├── admins.go │ ├── admins_test.go │ ├── courses.go │ ├── emails.go │ ├── files.go │ ├── lessons.go │ ├── mocks │ └── mock.go │ ├── modules.go │ ├── offers.go │ ├── orders.go │ ├── packages.go │ ├── payments.go │ ├── promocodes.go │ ├── schools.go │ ├── service.go │ ├── student_lessons.go │ ├── students.go │ ├── surveys.go │ └── users.go ├── pkg ├── auth │ └── manager.go ├── cache │ ├── cache.go │ └── memory.go ├── database │ └── mongodb │ │ └── mongodb.go ├── dns │ └── dns.go ├── email │ ├── mock │ │ └── mock.go │ ├── provider.go │ ├── sender.go │ ├── sendpulse │ │ └── client.go │ ├── smtp │ │ └── smtp.go │ └── validate.go ├── hash │ └── password.go ├── limiter │ └── limiter.go ├── logger │ ├── logger.go │ └── logrus.go ├── otp │ ├── mock.go │ └── otp.go ├── payment │ ├── fondy │ │ └── fondy.go │ └── provider.go └── storage │ ├── minio.go │ └── storage.go ├── templates ├── purchase_successful.html └── verification_email.html └── tests ├── admins_test.go ├── courses_test.go ├── data.go ├── fixtures ├── callback_approved.json └── callback_declined.json ├── main_test.go ├── payment_test.go ├── promocodes_test.go └── students_test.go /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: Linter & Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | - '!main' 8 | - '!develop' 9 | pull_request: 10 | branches: 11 | - '**' 12 | - '!main' 13 | - '!develop' 14 | 15 | env: 16 | TEST_CONTAINER_NAME: "test_db" 17 | TEST_DB_NAME: "test" 18 | TEST_DB_URI: "mongodb://localhost:27019" 19 | 20 | jobs: 21 | 22 | linter: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: golangci-lint 27 | uses: golangci/golangci-lint-action@v2 28 | with: 29 | version: v1.41 30 | 31 | tests: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Install Go 35 | uses: actions/setup-go@v2 36 | with: 37 | go-version: 1.17 38 | 39 | - name: Checkout code 40 | uses: actions/checkout@v2 41 | 42 | - name: Unit Tests 43 | run: go test --short ./... 44 | 45 | - name: Create test db container 46 | run: docker run --rm -d -p 27019:27017 --name $(echo $TEST_CONTAINER_NAME) -e MONGODB_DATABASE=$(echo $TEST_DB_NAME) mongo:4.4-bionic 47 | 48 | - name: Integration Tests 49 | run: GIN_MODE=release go test -v ./tests/ 50 | 51 | - name: Kill test db container 52 | run: docker stop $(echo $TEST_CONTAINER_NAME) -------------------------------------------------------------------------------- /.github/workflows/stage.yml: -------------------------------------------------------------------------------- 1 | name: CI-stage 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | env: 9 | REGISTRY: "registry.digitalocean.com/sandbox-registry" 10 | API_IMAGE: "courses-backend" 11 | NGINX_IMAGE: "courses-backend-proxy" 12 | TAG: "staging" 13 | TEST_CONTAINER_NAME: "test_db" 14 | TEST_DB_NAME: "test" 15 | TEST_DB_URI: "mongodb://localhost:27019" 16 | APP_ENV: "stage" 17 | 18 | jobs: 19 | 20 | tests: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Install Go 24 | uses: actions/setup-go@v2 25 | with: 26 | go-version: 1.17 27 | 28 | - name: Checkout code 29 | uses: actions/checkout@v2 30 | 31 | - name: Unit Tests 32 | run: go test --short ./... 33 | 34 | - name: Create test db container 35 | run: docker run --rm -d -p 27019:27017 --name $(echo $TEST_CONTAINER_NAME) -e MONGODB_DATABASE=$(echo $TEST_DB_NAME) mongo:4.4-bionic 36 | 37 | - name: Integration Tests 38 | run: GIN_MODE=release go test -v ./tests/ 39 | 40 | - name: Kill test db container 41 | run: docker stop $(echo $TEST_CONTAINER_NAME) 42 | 43 | build_and_push: 44 | runs-on: ubuntu-latest 45 | needs: tests 46 | 47 | steps: 48 | - name: Checkout code 49 | uses: actions/checkout@v2 50 | 51 | - name: Build API container image 52 | run: docker build -f deploy/Dockerfile -t $(echo $REGISTRY)/$(echo $API_IMAGE):$(echo $TAG) . 53 | - name: Build NGINX container image 54 | run: docker build -f deploy/nginx/Dockerfile -t $(echo $REGISTRY)/$(echo $NGINX_IMAGE):$(echo $TAG) . 55 | 56 | - name: Install doctl 57 | uses: digitalocean/action-doctl@v2 58 | with: 59 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 60 | 61 | - name: Log in to DigitalOcean Container Registry with short-lived credentials 62 | run: doctl registry login --expiry-seconds 600 63 | 64 | - name: Push API image to DigitalOcean Container Registry 65 | run: docker push $(echo $REGISTRY)/$(echo $API_IMAGE):$(echo $TAG) 66 | 67 | - name: Push NGINX image to DigitalOcean Container Registry 68 | run: docker push $(echo $REGISTRY)/$(echo $NGINX_IMAGE):$(echo $TAG) 69 | 70 | deploy: 71 | environment: stage 72 | runs-on: ubuntu-latest 73 | needs: build_and_push 74 | 75 | steps: 76 | - uses: actions/checkout@master 77 | - name: copy file via ssh password 78 | uses: appleboy/scp-action@master 79 | with: 80 | host: ${{ secrets.HOST }} 81 | username: ${{ secrets.USERNAME }} 82 | key: ${{ secrets.SSHKEY }} 83 | source: "deploy/,!deploy/nginx,!deploy/Dockerfile" 84 | target: "api" 85 | strip_components: 1 86 | 87 | - name: Deploy to Digital Ocean droplet via SSH action 88 | uses: appleboy/ssh-action@v0.1.3 89 | env: 90 | SERVER_NAME: "api-stage.creatly.me" 91 | with: 92 | host: ${{ secrets.HOST }} 93 | username: ${{ secrets.USERNAME }} 94 | key: ${{ secrets.SSHKEY }} 95 | envs: API_IMAGE,NGINX_IMAGE,TAG,REGISTRY,SERVER_NAME,APP_ENV 96 | script: | 97 | # Set env variables 98 | export MONGO_URI="${{ secrets.MONGO_URI }}" 99 | export MONGO_USER="${{ secrets.MONGO_USER }}" 100 | export MONGO_PASS="${{ secrets.MONGO_PASS }}" 101 | export PASSWORD_SALT="${{ secrets.PASSWORD_SALT }}" 102 | export JWT_SIGNING_KEY="${{ secrets.JWT_SIGNING_KEY }}" 103 | export SENDPULSE_LISTID="${{ secrets.SENDPULSE_LISTID }}" 104 | export SENDPULSE_ID="${{ secrets.SENDPULSE_ID }}" 105 | export SENDPULSE_SECRET="${{ secrets.SENDPULSE_SECRET }}" 106 | export HTTP_HOST="${{secrets.HTTP_HOST}}" 107 | export FONDY_MERCHANT_ID=${{secrets.FONDY_MERCHANT_ID}} 108 | export FONDY_MERCHANT_PASS=${{secrets.FONDY_MERCHANT_PASS}} 109 | export PAYMENT_CALLBACK_URL=${{secrets.PAYMENT_CALLBACK_URL}} 110 | export PAYMENT_REDIRECT_URL=${{secrets.PAYMENT_REDIRECT_URL}} 111 | export FRONTEND_URL=${{secrets.FRONTEND_URL}} 112 | export SMTP_PASSWORD=${{secrets.SMTP_PASSWORD}} 113 | export SERVER_NAME=$(echo $SERVER_NAME) 114 | export REGISTRY=$(echo $REGISTRY) 115 | export API_IMAGE=$(echo $API_IMAGE) 116 | export NGINX_IMAGE=$(echo $NGINX_IMAGE) 117 | export TAG=$(echo $TAG) 118 | 119 | export APP_ENV=$(echo $APP_ENV) 120 | 121 | export STORAGE_ENDPOINT=${{secrets.STORAGE_ENDPOINT}} 122 | export STORAGE_BUCKET=${{secrets.STORAGE_BUCKET}} 123 | export STORAGE_ACCESS_KEY=${{secrets.STORAGE_ACCESS_KEY}} 124 | export STORAGE_SECRET_KEY="${{secrets.STORAGE_SECRET_KEY}}" 125 | 126 | export CLOUDFLARE_API_KEY=${{secrets.CLOUDFLARE_API_KEY}} 127 | export CLOUDFLARE_EMAIL=${{secrets.CLOUDFLARE_EMAIL}} 128 | export CLOUDFLARE_ZONE_EMAIL=${{secrets.CLOUDFLARE_ZONE_EMAIL}} 129 | export CLOUDFLARE_CNAME_TARGET=${{secrets.CLOUDFLARE_CNAME_TARGET}} 130 | 131 | # Login into Digital Ocean Registry 132 | docker login -u ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} -p ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} $(echo $REGISTRY) 133 | 134 | # Run a new container from a new image 135 | cd api 136 | docker-compose -f staging.yml stop 137 | docker-compose -f staging.yml rm -f 138 | docker-compose -f staging.yml pull 139 | docker-compose -f staging.yml up -d --force-recreate nginx -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .data 3 | .bin 4 | .DS_Store 5 | 6 | .env 7 | cover.out 8 | 9 | # Visual Studio Settings 10 | .vscode -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 1m 3 | 4 | linters: 5 | disable-all: true 6 | enable: 7 | - asciicheck 8 | - deadcode 9 | - depguard 10 | - dogsled 11 | - errcheck 12 | - exhaustive 13 | - exportloopref 14 | - gocognit 15 | - gocyclo 16 | - gofmt 17 | - gofumpt 18 | - goheader 19 | - goimports 20 | - gomodguard 21 | - goprintffuncname 22 | - gosimple 23 | - govet 24 | - ineffassign 25 | - misspell 26 | - nakedret 27 | - nestif 28 | - rowserrcheck 29 | - sqlclosecheck 30 | - staticcheck 31 | - structcheck 32 | - typecheck 33 | - unconvert 34 | - unused 35 | - varcheck 36 | - whitespace 37 | - durationcheck 38 | - forbidigo 39 | - forcetypeassert 40 | - ifshort 41 | - importas 42 | - nilerr 43 | - predeclared 44 | - thelper 45 | - tparallel 46 | - wastedassign 47 | - promlinter 48 | - bodyclose 49 | # - dupl 50 | - godot 51 | - funlen 52 | - wsl 53 | # - gochecknoglobals 54 | # - gochecknoinits 55 | # - goconst 56 | - gocritic 57 | # - godox 58 | # - goerr113 59 | # - gosec 60 | # - noctx 61 | - prealloc 62 | # - stylecheck 63 | # - testpackage 64 | # - cyclop 65 | # - errorlint 66 | - nlreturn 67 | # - revive 68 | # - tagliatelle 69 | - unparam 70 | 71 | issues: 72 | exclude-rules: 73 | - path: (_test\.go|tests) 74 | linters: 75 | - bodyclose 76 | - dupl 77 | - funlen 78 | - goerr113 79 | #- gosec 80 | - noctx 81 | - path: (internal/delivery/http) 82 | linters: 83 | - godot 84 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | RUN apk --no-cache add ca-certificates 4 | WORKDIR /root/ 5 | 6 | CMD ["./app"] -------------------------------------------------------------------------------- /Dockerfile.debug: -------------------------------------------------------------------------------- 1 | FROM golang:1.17.3-alpine3.14 AS build-env 2 | 3 | ENV CGO_ENABLED 0 4 | 5 | RUN apk add --no-cache git 6 | RUN go install github.com/go-delve/delve/cmd/dlv@latest 7 | 8 | # final stage 9 | FROM alpine:3.11 10 | 11 | WORKDIR / 12 | COPY --from=build-env /go/bin/dlv / 13 | 14 | WORKDIR /root/ 15 | 16 | CMD ["/dlv", "--listen=:2345", "--headless=true", "--api-version=2", "exec", "./app"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: 2 | .SILENT: 3 | .DEFAULT_GOAL := run 4 | 5 | build: 6 | go mod download && CGO_ENABLED=0 GOOS=linux go build -o ./.bin/app ./cmd/app/main.go 7 | 8 | run: build 9 | docker-compose up --remove-orphans app 10 | 11 | debug: build 12 | docker-compose up --remove-orphans debug 13 | 14 | test: 15 | go test --short -coverprofile=cover.out -v ./... 16 | make test.coverage 17 | 18 | # Testing Vars 19 | export TEST_DB_URI=mongodb://localhost:27019 20 | export TEST_DB_NAME=test 21 | export TEST_CONTAINER_NAME=test_db 22 | 23 | test.integration: 24 | docker run --rm -d -p 27019:27017 --name $$TEST_CONTAINER_NAME -e MONGODB_DATABASE=$$TEST_DB_NAME mongo:4.4-bionic 25 | 26 | GIN_MODE=release go test -v ./tests/ 27 | docker stop $$TEST_CONTAINER_NAME 28 | 29 | test.coverage: 30 | go tool cover -func=cover.out | grep "total" 31 | 32 | swag: 33 | swag init -g internal/app/app.go 34 | 35 | lint: 36 | golangci-lint run 37 | 38 | gen: 39 | mockgen -source=internal/service/service.go -destination=internal/service/mocks/mock.go 40 | mockgen -source=internal/repository/repository.go -destination=internal/repository/mocks/mock.go -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Creatly LMS [Backend Application] ![GO][go-badge] 2 | 3 | [go-badge]: https://img.shields.io/github/go-mod/go-version/p12s/furniture-store?style=plastic 4 | [go-url]: https://github.com/p12s/furniture-store/blob/master/go.mod 5 | 6 | Learn More about Creatly [here](https://zhashkevych.notion.site/About-Creatly-Creaty-8c68a310ec2347fca80ba919692fa568) 7 | 8 | ## Build & Run (Locally) 9 | ### Prerequisites 10 | - go 1.17 11 | - docker & docker-compose 12 | - [golangci-lint](https://github.com/golangci/golangci-lint) (optional, used to run code checks) 13 | - [swag](https://github.com/swaggo/swag) (optional, used to re-generate swagger documentation) 14 | 15 | Create .env file in root directory and add following values: 16 | ```dotenv 17 | APP_ENV=local 18 | 19 | MONGO_URI=mongodb://mongodb:27017 20 | MONGO_USER=admin 21 | MONGO_PASS=qwerty 22 | 23 | PASSWORD_SALT= 24 | JWT_SIGNING_KEY= 25 | 26 | SENDPULSE_LISTID= 27 | SENDPULSE_ID= 28 | SENDPULSE_SECRET= 29 | 30 | HTTP_HOST=localhost 31 | 32 | FONDY_MERCHANT_ID=1396424 33 | FONDY_MERCHANT_PASS=test 34 | PAYMENT_CALLBACK_URL=/api/v1/callback/fondy 35 | PAYMENT_REDIRECT_URL=https://example.com/ 36 | 37 | SMTP_PASSWORD= 38 | 39 | STORAGE_ENDPOINT= 40 | STORAGE_BUCKET= 41 | STORAGE_ACCESS_KEY= 42 | STORAGE_SECRET_KEY= 43 | 44 | CLOUDFLARE_API_KEY= 45 | CLOUDFLARE_EMAIL= 46 | CLOUDFLARE_ZONE_EMAIL= 47 | CLOUDFLARE_CNAME_TARGET= 48 | ``` 49 | 50 | Use `make run` to build&run project, `make lint` to check code with linter. -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/zhashkevych/creatly-backend/internal/app" 4 | 5 | const configsDir = "configs" 6 | 7 | func main() { 8 | app.Run(configsDir) 9 | } 10 | -------------------------------------------------------------------------------- /configs/main.yml: -------------------------------------------------------------------------------- 1 | http: 2 | port: 8000 3 | maxHeaderBytes: 1 4 | readTimeout: 10s 5 | writeTimeout: 10s 6 | 7 | cache: 8 | ttl: 60s 9 | 10 | mongo: 11 | databaseName: coursePlatform 12 | 13 | fileStorage: 14 | url: ams3.digitaloceanspaces.com 15 | bucket: courses 16 | 17 | auth: 18 | accessTokenTTL: 2h 19 | refreshTokenTTL: 720h #30 days 20 | verificationCodeLength: 8 21 | 22 | limiter: 23 | rps: 10 24 | burst: 20 25 | ttl: 10m 26 | 27 | # todo: smtp settings should be configurable for each school individually 28 | smtp: 29 | host: "mail.privateemail.com" 30 | port: 587 31 | from: "no-reply@creatly.me" 32 | 33 | email: 34 | templates: 35 | verification_email: "./templates/verification_email.html" 36 | purchase_successful: "./templates/purchase_successful.html" 37 | subjects: 38 | verification_email: "Спасибо за регистрацию, %s!" 39 | purchase_successful: "Покупка прошла успешно!" -------------------------------------------------------------------------------- /configs/prod.yml: -------------------------------------------------------------------------------- 1 | auth: 2 | accessTokenTTL: 2h 3 | refreshTokenTTL: 720h -------------------------------------------------------------------------------- /configs/stage.yml: -------------------------------------------------------------------------------- 1 | auth: 2 | accessTokenTTL: 15m 3 | refreshTokenTTL: 1h -------------------------------------------------------------------------------- /deploy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15-alpine3.12 AS builder 2 | 3 | RUN go version 4 | 5 | COPY . /github.com/zhashkevych/creatly-backend/ 6 | WORKDIR /github.com/zhashkevych/creatly-backend/ 7 | 8 | RUN go mod download 9 | RUN GOOS=linux go build -o ./.bin/app ./cmd/app/main.go 10 | 11 | FROM alpine:latest 12 | 13 | WORKDIR /root/ 14 | 15 | COPY --from=0 /github.com/zhashkevych/creatly-backend/.bin/app . 16 | COPY --from=0 /github.com/zhashkevych/creatly-backend/configs configs/ 17 | COPY --from=0 /github.com/zhashkevych/creatly-backend/templates templates/ 18 | 19 | CMD ["./app"] -------------------------------------------------------------------------------- /deploy/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | api: 5 | image: ${REGISTRY}/${API_IMAGE}:${TAG} 6 | container_name: courses-api 7 | restart: always 8 | ports: 9 | - 8000:8000 10 | logging: 11 | driver: syslog 12 | options: 13 | tag: "api-production" 14 | environment: 15 | - MONGO_URI 16 | - MONGO_USER 17 | - MONGO_PASS 18 | - PASSWORD_SALT 19 | - JWT_SIGNING_KEY 20 | - SENDPULSE_LISTID 21 | - SENDPULSE_ID 22 | - SENDPULSE_SECRET 23 | - HTTP_HOST 24 | - FONDY_MERCHANT_ID 25 | - FONDY_MERCHANT_PASS 26 | - PAYMENT_CALLBACK_URL 27 | - PAYMENT_REDIRECT_URL 28 | - FRONTEND_URL 29 | - SMTP_PASSWORD 30 | - STORAGE_ENDPOINT 31 | - STORAGE_BUCKET 32 | - STORAGE_ACCESS_KEY 33 | - STORAGE_SECRET_KEY 34 | - APP_ENV 35 | - CLOUDFLARE_API_KEY 36 | - CLOUDFLARE_EMAIL 37 | - CLOUDFLARE_ZONE_EMAIL 38 | - CLOUDFLARE_CNAME_TARGET 39 | 40 | nginx: 41 | image: ${REGISTRY}/${NGINX_IMAGE}:${TAG} 42 | container_name: courses-api-proxy 43 | restart: always 44 | volumes: 45 | - ./certs/:/etc/nginx/certs/ 46 | ports: 47 | - 80:80 48 | - 443:443 49 | environment: 50 | - API_HOST=courses-api 51 | - API_PORT=8000 52 | - SERVER_NAME=${SERVER_NAME} 53 | depends_on: 54 | - api -------------------------------------------------------------------------------- /deploy/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.15-alpine 2 | 3 | COPY ./deploy/nginx/nginx.conf.template / 4 | COPY ./deploy/nginx/docker-entrypoint.sh / 5 | 6 | RUN chmod +x /docker-entrypoint.sh 7 | ENTRYPOINT ["sh", "/docker-entrypoint.sh"] 8 | 9 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /deploy/nginx/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -eu 3 | 4 | envsubst '${API_HOST} ${API_PORT} ${SERVER_NAME}' < /nginx.conf.template > /etc/nginx/nginx.conf 5 | 6 | exec "$@" -------------------------------------------------------------------------------- /deploy/nginx/nginx.conf.template: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | error_log /var/log/nginx/error.log warn; 3 | pid /var/run/nginx.pid; 4 | 5 | events { 6 | worker_connections 1024; 7 | } 8 | 9 | http { 10 | include /etc/nginx/mime.types; 11 | default_type application/octet-stream; 12 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 13 | '$status $body_bytes_sent "$http_referer" ' 14 | '"$http_user_agent" "$http_x_forwarded_for"'; 15 | access_log /var/log/nginx/access.log main; 16 | sendfile on; 17 | keepalive_timeout 65; 18 | 19 | client_max_body_size 0; 20 | 21 | server { 22 | listen 80; 23 | listen [::]:80; 24 | server_name ${SERVER_NAME}; 25 | return 302 https://$server_name$request_uri; 26 | } 27 | 28 | server { 29 | listen 443 ssl; 30 | server_name ${SERVER_NAME}; 31 | ssl_certificate /etc/nginx/certs/api.prod.cert.pem; 32 | ssl_certificate_key /etc/nginx/certs/api.prod.key.pem; 33 | 34 | location / { 35 | proxy_pass http://${API_HOST}:${API_PORT}; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /deploy/staging.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | api: 5 | image: ${REGISTRY}/${API_IMAGE}:${TAG} 6 | container_name: courses-api 7 | restart: always 8 | depends_on: 9 | - mongodb 10 | ports: 11 | - 8000:8000 12 | environment: 13 | - MONGO_URI 14 | - MONGO_USER 15 | - MONGO_PASS 16 | - PASSWORD_SALT 17 | - JWT_SIGNING_KEY 18 | - SENDPULSE_LISTID 19 | - SENDPULSE_ID 20 | - SENDPULSE_SECRET 21 | - HTTP_HOST 22 | - FONDY_MERCHANT_ID 23 | - FONDY_MERCHANT_PASS 24 | - PAYMENT_CALLBACK_URL 25 | - PAYMENT_REDIRECT_URL 26 | - FRONTEND_URL 27 | - SMTP_PASSWORD 28 | - STORAGE_ENDPOINT 29 | - STORAGE_BUCKET 30 | - STORAGE_ACCESS_KEY 31 | - STORAGE_SECRET_KEY 32 | - APP_ENV 33 | - CLOUDFLARE_API_KEY 34 | - CLOUDFLARE_EMAIL 35 | - CLOUDFLARE_ZONE_EMAIL 36 | - CLOUDFLARE_CNAME_TARGET 37 | 38 | mongodb: 39 | image: mongo:4.4-bionic 40 | container_name: mongodb 41 | restart: always 42 | environment: 43 | - MONGO_DATA_DIR=/data/db 44 | - MONGO_LOG_DIR=/dev/null 45 | - MONGODB_DATABASE=coursePlatform 46 | - MONGO_INITDB_ROOT_USERNAME=${MONGO_USER} 47 | - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS} 48 | volumes: 49 | - ./.data/db:/data/db 50 | ports: 51 | - 27017:27017 52 | 53 | nginx: 54 | image: ${REGISTRY}/${NGINX_IMAGE}:${TAG} 55 | container_name: courses-api-proxy 56 | restart: always 57 | volumes: 58 | - ./certs/:/etc/nginx/certs/ 59 | ports: 60 | - 80:80 61 | - 443:443 62 | environment: 63 | - API_HOST=courses-api 64 | - API_PORT=8000 65 | - SERVER_NAME=${SERVER_NAME} 66 | depends_on: 67 | - api -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | app: 5 | image: creatly-backend-app 6 | container_name: creatly-backend-app 7 | build: 8 | context: . 9 | dockerfile: Dockerfile 10 | ports: 11 | - 8000:8000 12 | depends_on: 13 | - mongodb 14 | volumes: 15 | - ./.bin/:/root/ 16 | - ./configs/:/root/configs/ 17 | - ./templates/:/root/templates/ 18 | env_file: 19 | - .env 20 | 21 | debug: 22 | image: creatly-backend-debug 23 | container_name: creatly-backend-debug 24 | build: 25 | context: . 26 | dockerfile: Dockerfile.debug 27 | ports: 28 | - "8000:8000" 29 | - "2345:2345" 30 | depends_on: 31 | - mongodb 32 | volumes: 33 | - ./.bin/:/root/ 34 | - ./configs/:/root/configs/ 35 | - ./templates/:/root/templates/ 36 | env_file: 37 | - .env 38 | 39 | mongodb: 40 | image: mongo:4.4-bionic 41 | container_name: mongodb 42 | environment: 43 | - MONGO_DATA_DIR=/data/db 44 | - MONGO_LOG_DIR=/dev/null 45 | - MONGODB_DATABASE=coursePlatform 46 | - MONGO_INITDB_ROOT_USERNAME=admin 47 | - MONGO_INITDB_ROOT_PASSWORD=qwerty 48 | volumes: 49 | - ./.data/db:/data/db 50 | ports: 51 | - 27018:27017 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zhashkevych/creatly-backend 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.36.29 // indirect 7 | github.com/cloudflare/cloudflare-go v0.17.0 8 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 9 | github.com/fatih/structs v1.1.0 10 | github.com/fsnotify/fsnotify v1.4.9 // indirect 11 | github.com/gin-gonic/gin v1.6.3 12 | github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df 13 | github.com/go-openapi/jsonreference v0.19.6 // indirect 14 | github.com/go-openapi/spec v0.20.3 // indirect 15 | github.com/go-openapi/swag v0.19.15 // indirect 16 | github.com/go-playground/assert/v2 v2.0.1 17 | github.com/go-playground/validator/v10 v10.4.1 // indirect 18 | github.com/golang/mock v1.5.0 19 | github.com/golang/protobuf v1.4.3 // indirect 20 | github.com/golang/snappy v0.0.2 // indirect 21 | github.com/google/uuid v1.2.0 22 | github.com/klauspost/compress v1.11.7 // indirect 23 | github.com/klauspost/cpuid/v2 v2.0.6 // indirect 24 | github.com/leodido/go-urn v1.2.1 // indirect 25 | github.com/magiconair/properties v1.8.4 // indirect 26 | github.com/mailru/easyjson v0.7.7 // indirect 27 | github.com/minio/md5-simd v1.1.2 // indirect 28 | github.com/minio/minio-go/v7 v7.0.10 29 | github.com/minio/sha256-simd v1.0.0 // indirect 30 | github.com/mitchellh/mapstructure v1.4.1 // indirect 31 | github.com/pelletier/go-toml v1.8.1 // indirect 32 | github.com/pkg/errors v0.9.1 33 | github.com/rs/xid v1.3.0 // indirect 34 | github.com/sirupsen/logrus v1.7.0 35 | github.com/spf13/afero v1.5.1 // indirect 36 | github.com/spf13/cast v1.3.1 // indirect 37 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 38 | github.com/spf13/pflag v1.0.5 // indirect 39 | github.com/spf13/viper v1.7.1 40 | github.com/stretchr/testify v1.7.0 41 | github.com/swaggo/gin-swagger v1.3.0 42 | github.com/swaggo/swag v1.7.0 43 | github.com/xlzd/gotp v0.0.0-20181030022105-c8557ba2c119 44 | go.mongodb.org/mongo-driver v1.4.5 45 | golang.org/x/crypto v0.0.0-20210415154028-4f45737414dc // indirect 46 | golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect 47 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect 48 | golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba 49 | golang.org/x/tools v0.1.5 // indirect 50 | google.golang.org/protobuf v1.25.0 // indirect 51 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 52 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect 53 | gopkg.in/ini.v1 v1.62.0 // indirect 54 | ) 55 | 56 | require ( 57 | github.com/KyleBanks/depth v1.2.1 // indirect 58 | github.com/PuerkitoBio/purell v1.1.1 // indirect 59 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 60 | github.com/davecgh/go-spew v1.1.1 // indirect 61 | github.com/gin-contrib/sse v0.1.0 // indirect 62 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 63 | github.com/go-playground/locales v0.13.0 // indirect 64 | github.com/go-playground/universal-translator v0.17.0 // indirect 65 | github.com/go-stack/stack v1.8.0 // indirect 66 | github.com/hashicorp/hcl v1.0.0 // indirect 67 | github.com/jmespath/go-jmespath v0.4.0 // indirect 68 | github.com/josharian/intern v1.0.0 // indirect 69 | github.com/json-iterator/go v1.1.10 // indirect 70 | github.com/mattn/go-isatty v0.0.12 // indirect 71 | github.com/mitchellh/go-homedir v1.1.0 // indirect 72 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 73 | github.com/modern-go/reflect2 v1.0.1 // indirect 74 | github.com/pmezard/go-difflib v1.0.0 // indirect 75 | github.com/stretchr/objx v0.1.1 // indirect 76 | github.com/subosito/gotenv v1.2.0 // indirect 77 | github.com/ugorji/go/codec v1.2.3 // indirect 78 | github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c // indirect 79 | github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc // indirect 80 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect 81 | golang.org/x/text v0.3.6 // indirect 82 | gopkg.in/yaml.v2 v2.4.0 // indirect 83 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect 84 | ) 85 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | //nolint: funlen 2 | package app 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/cloudflare/cloudflare-go" 14 | "github.com/zhashkevych/creatly-backend/pkg/dns" 15 | "github.com/zhashkevych/creatly-backend/pkg/email/smtp" 16 | 17 | "github.com/minio/minio-go/v7" 18 | "github.com/minio/minio-go/v7/pkg/credentials" 19 | "github.com/zhashkevych/creatly-backend/pkg/storage" 20 | 21 | "github.com/zhashkevych/creatly-backend/internal/config" 22 | delivery "github.com/zhashkevych/creatly-backend/internal/delivery/http" 23 | "github.com/zhashkevych/creatly-backend/internal/repository" 24 | "github.com/zhashkevych/creatly-backend/internal/server" 25 | "github.com/zhashkevych/creatly-backend/internal/service" 26 | "github.com/zhashkevych/creatly-backend/pkg/auth" 27 | "github.com/zhashkevych/creatly-backend/pkg/cache" 28 | "github.com/zhashkevych/creatly-backend/pkg/database/mongodb" 29 | "github.com/zhashkevych/creatly-backend/pkg/hash" 30 | "github.com/zhashkevych/creatly-backend/pkg/logger" 31 | "github.com/zhashkevych/creatly-backend/pkg/otp" 32 | ) 33 | 34 | // @title Creatly API 35 | // @version 1.0 36 | // @description REST API for Creatly App 37 | 38 | // @host localhost:8000 39 | // @BasePath /api/v1/ 40 | 41 | // @securityDefinitions.apikey AdminAuth 42 | // @in header 43 | // @name Authorization 44 | 45 | // @securityDefinitions.apikey StudentsAuth 46 | // @in header 47 | // @name Authorization 48 | 49 | // @securityDefinitions.apikey UsersAuth 50 | // @in header 51 | // @name Authorization 52 | 53 | // Run initializes whole application. 54 | func Run(configPath string) { 55 | cfg, err := config.Init(configPath) 56 | if err != nil { 57 | logger.Error(err) 58 | 59 | return 60 | } 61 | 62 | // Dependencies 63 | mongoClient, err := mongodb.NewClient(cfg.Mongo.URI, cfg.Mongo.User, cfg.Mongo.Password) 64 | if err != nil { 65 | logger.Error(err) 66 | 67 | return 68 | } 69 | 70 | db := mongoClient.Database(cfg.Mongo.Name) 71 | 72 | memCache := cache.NewMemoryCache() 73 | hasher := hash.NewSHA1Hasher(cfg.Auth.PasswordSalt) 74 | 75 | emailSender, err := smtp.NewSMTPSender(cfg.SMTP.From, cfg.SMTP.Pass, cfg.SMTP.Host, cfg.SMTP.Port) 76 | if err != nil { 77 | logger.Error(err) 78 | 79 | return 80 | } 81 | 82 | tokenManager, err := auth.NewManager(cfg.Auth.JWT.SigningKey) 83 | if err != nil { 84 | logger.Error(err) 85 | 86 | return 87 | } 88 | 89 | otpGenerator := otp.NewGOTPGenerator() 90 | 91 | storageProvider, err := newStorageProvider(cfg) 92 | if err != nil { 93 | logger.Error(err) 94 | 95 | return 96 | } 97 | 98 | cloudflareClient, err := cloudflare.New(cfg.Cloudflare.ApiKey, cfg.Cloudflare.Email) 99 | if err != nil { 100 | logger.Error(err) 101 | 102 | return 103 | } 104 | 105 | dnsService := dns.NewService(cloudflareClient, cfg.Cloudflare.ZoneEmail, cfg.Cloudflare.CnameTarget) 106 | 107 | // Services, Repos & API Handlers 108 | repos := repository.NewRepositories(db) 109 | services := service.NewServices(service.Deps{ 110 | Repos: repos, 111 | Cache: memCache, 112 | Hasher: hasher, 113 | TokenManager: tokenManager, 114 | EmailSender: emailSender, 115 | EmailConfig: cfg.Email, 116 | AccessTokenTTL: cfg.Auth.JWT.AccessTokenTTL, 117 | RefreshTokenTTL: cfg.Auth.JWT.RefreshTokenTTL, 118 | FondyCallbackURL: cfg.Payment.FondyCallbackURL, 119 | CacheTTL: int64(cfg.CacheTTL.Seconds()), 120 | OtpGenerator: otpGenerator, 121 | VerificationCodeLength: cfg.Auth.VerificationCodeLength, 122 | StorageProvider: storageProvider, 123 | Environment: cfg.Environment, 124 | Domain: cfg.HTTP.Host, 125 | DNS: dnsService, 126 | }) 127 | handlers := delivery.NewHandler(services, tokenManager) 128 | 129 | services.Files.InitStorageUploaderWorkers(context.Background()) 130 | 131 | // HTTP Server 132 | srv := server.NewServer(cfg, handlers.Init(cfg)) 133 | 134 | go func() { 135 | if err := srv.Run(); !errors.Is(err, http.ErrServerClosed) { 136 | logger.Errorf("error occurred while running http server: %s\n", err.Error()) 137 | } 138 | }() 139 | 140 | logger.Info("Server started") 141 | 142 | // Graceful Shutdown 143 | quit := make(chan os.Signal, 1) 144 | signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) 145 | 146 | <-quit 147 | 148 | const timeout = 5 * time.Second 149 | 150 | ctx, shutdown := context.WithTimeout(context.Background(), timeout) 151 | defer shutdown() 152 | 153 | if err := srv.Stop(ctx); err != nil { 154 | logger.Errorf("failed to stop server: %v", err) 155 | } 156 | 157 | if err := mongoClient.Disconnect(context.Background()); err != nil { 158 | logger.Error(err.Error()) 159 | } 160 | } 161 | 162 | func newStorageProvider(cfg *config.Config) (storage.Provider, error) { 163 | client, err := minio.New(cfg.FileStorage.Endpoint, &minio.Options{ 164 | Creds: credentials.NewStaticV4(cfg.FileStorage.AccessKey, cfg.FileStorage.SecretKey, ""), 165 | Secure: true, 166 | }) 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | provider := storage.NewFileStorage(client, cfg.FileStorage.Bucket, cfg.FileStorage.Endpoint) 172 | 173 | return provider, nil 174 | } 175 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestInit(t *testing.T) { 11 | type env struct { 12 | mongoURI string 13 | mongoUser string 14 | mongoPass string 15 | passwordSalt string 16 | jwtSigningKey string 17 | host string 18 | fondyCallbackURL string 19 | frontendUrl string 20 | smtpPassword string 21 | appEnv string 22 | storageEndpoint string 23 | storageBucket string 24 | storageAccessKey string 25 | storageSecretKey string 26 | cloudflareApiKey string 27 | cloudflareEmail string 28 | cloudflareZoneEmail string 29 | cloudflareCnameTarget string 30 | } 31 | 32 | type args struct { 33 | path string 34 | env env 35 | } 36 | 37 | setEnv := func(env env) { 38 | os.Setenv("MONGO_URI", env.mongoURI) 39 | os.Setenv("MONGO_USER", env.mongoUser) 40 | os.Setenv("MONGO_PASS", env.mongoPass) 41 | os.Setenv("PASSWORD_SALT", env.passwordSalt) 42 | os.Setenv("JWT_SIGNING_KEY", env.jwtSigningKey) 43 | os.Setenv("HTTP_HOST", env.host) 44 | os.Setenv("FONDY_CALLBACK_URL", env.fondyCallbackURL) 45 | os.Setenv("FRONTEND_URL", env.frontendUrl) 46 | os.Setenv("SMTP_PASSWORD", env.smtpPassword) 47 | os.Setenv("APP_ENV", env.appEnv) 48 | os.Setenv("STORAGE_ENDPOINT", env.storageEndpoint) 49 | os.Setenv("STORAGE_BUCKET", env.storageBucket) 50 | os.Setenv("STORAGE_ACCESS_KEY", env.storageAccessKey) 51 | os.Setenv("STORAGE_SECRET_KEY", env.storageSecretKey) 52 | os.Setenv("CLOUDFLARE_API_KEY", env.cloudflareApiKey) 53 | os.Setenv("CLOUDFLARE_EMAIL", env.cloudflareEmail) 54 | os.Setenv("CLOUDFLARE_ZONE_EMAIL", env.cloudflareZoneEmail) 55 | os.Setenv("CLOUDFLARE_CNAME_TARGET", env.cloudflareCnameTarget) 56 | } 57 | 58 | tests := []struct { 59 | name string 60 | args args 61 | want *Config 62 | wantErr bool 63 | }{ 64 | { 65 | name: "test config", 66 | args: args{ 67 | path: "fixtures", 68 | env: env{ 69 | mongoURI: "mongodb://localhost:27017", 70 | mongoUser: "admin", 71 | mongoPass: "qwerty", 72 | passwordSalt: "salt", 73 | jwtSigningKey: "key", 74 | host: "localhost", 75 | fondyCallbackURL: "https://zhashkevych.com/callback", 76 | frontendUrl: "http://localhost:1337", 77 | smtpPassword: "qwerty123", 78 | appEnv: "local", 79 | storageEndpoint: "test.filestorage.com", 80 | storageBucket: "test", 81 | storageAccessKey: "qwerty123", 82 | storageSecretKey: "qwerty123", 83 | cloudflareApiKey: "api_key", 84 | cloudflareEmail: "email", 85 | cloudflareZoneEmail: "zone_email", 86 | cloudflareCnameTarget: "cname_target", 87 | }, 88 | }, 89 | want: &Config{ 90 | Environment: "local", 91 | CacheTTL: time.Second * 3600, 92 | HTTP: HTTPConfig{ 93 | Host: "localhost", 94 | MaxHeaderMegabytes: 1, 95 | Port: "80", 96 | ReadTimeout: time.Second * 10, 97 | WriteTimeout: time.Second * 10, 98 | }, 99 | Auth: AuthConfig{ 100 | PasswordSalt: "salt", 101 | JWT: JWTConfig{ 102 | RefreshTokenTTL: time.Minute * 30, 103 | AccessTokenTTL: time.Minute * 15, 104 | SigningKey: "key", 105 | }, 106 | VerificationCodeLength: 10, 107 | }, 108 | Mongo: MongoConfig{ 109 | Name: "testDatabase", 110 | URI: "mongodb://localhost:27017", 111 | User: "admin", 112 | Password: "qwerty", 113 | }, 114 | FileStorage: FileStorageConfig{ 115 | Endpoint: "test.filestorage.com", 116 | Bucket: "test", 117 | AccessKey: "qwerty123", 118 | SecretKey: "qwerty123", 119 | }, 120 | Email: EmailConfig{ 121 | Templates: EmailTemplates{ 122 | Verification: "./templates/verification_email.html", 123 | PurchaseSuccessful: "./templates/purchase_successful.html", 124 | }, 125 | Subjects: EmailSubjects{ 126 | Verification: "Спасибо за регистрацию, %s!", 127 | PurchaseSuccessful: "Покупка прошла успешно!", 128 | }, 129 | }, 130 | Payment: PaymentConfig{ 131 | FondyCallbackURL: "https://zhashkevych.com/callback", 132 | }, 133 | Limiter: LimiterConfig{ 134 | RPS: 10, 135 | Burst: 2, 136 | TTL: time.Minute * 10, 137 | }, 138 | SMTP: SMTPConfig{ 139 | Host: "mail.privateemail.com", 140 | Port: 587, 141 | From: "maksim@zhashkevych.com", 142 | Pass: "qwerty123", 143 | }, 144 | Cloudflare: CloudflareConfig{ 145 | ApiKey: "api_key", 146 | Email: "email", 147 | CnameTarget: "cname_target", 148 | ZoneEmail: "zone_email", 149 | }, 150 | }, 151 | }, 152 | } 153 | 154 | for _, tt := range tests { 155 | t.Run(tt.name, func(t *testing.T) { 156 | setEnv(tt.args.env) 157 | 158 | got, err := Init(tt.args.path) 159 | if (err != nil) != tt.wantErr { 160 | t.Errorf("Init() error = %v, wantErr %v", err, tt.wantErr) 161 | 162 | return 163 | } 164 | if !reflect.DeepEqual(got, tt.want) { 165 | t.Errorf("Init() got = %v, want %v", got, tt.want) 166 | } 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /internal/config/fixtures/main.yml: -------------------------------------------------------------------------------- 1 | http: 2 | port: 80 3 | maxHeaderBytes: 1 4 | readTimeout: 10s 5 | writeTimeout: 10s 6 | 7 | cache: 8 | ttl: 3600s 9 | 10 | mongo: 11 | databaseName: testDatabase 12 | 13 | fileStorage: 14 | url: test.filestorage.com 15 | bucket: test 16 | 17 | auth: 18 | accessTokenTTL: 15m 19 | refreshTokenTTL: 30m 20 | verificationCodeLength: 10 21 | 22 | limiter: 23 | rps: 10 24 | burst: 2 25 | ttl: 10m 26 | 27 | smtp: 28 | host: "mail.privateemail.com" 29 | port: 587 30 | from: "maksim@zhashkevych.com" 31 | 32 | 33 | email: 34 | templates: 35 | verification_email: "./templates/verification_email.html" 36 | purchase_successful: "./templates/purchase_successful.html" 37 | subjects: 38 | verification_email: "Спасибо за регистрацию, %s!" 39 | purchase_successful: "Покупка прошла успешно!" -------------------------------------------------------------------------------- /internal/delivery/http/handler.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | ginSwagger "github.com/swaggo/gin-swagger" 9 | "github.com/swaggo/gin-swagger/swaggerFiles" 10 | "github.com/zhashkevych/creatly-backend/docs" 11 | "github.com/zhashkevych/creatly-backend/internal/config" 12 | v1 "github.com/zhashkevych/creatly-backend/internal/delivery/http/v1" 13 | "github.com/zhashkevych/creatly-backend/internal/service" 14 | "github.com/zhashkevych/creatly-backend/pkg/auth" 15 | "github.com/zhashkevych/creatly-backend/pkg/limiter" 16 | ) 17 | 18 | type Handler struct { 19 | services *service.Services 20 | tokenManager auth.TokenManager 21 | } 22 | 23 | func NewHandler(services *service.Services, tokenManager auth.TokenManager) *Handler { 24 | return &Handler{ 25 | services: services, 26 | tokenManager: tokenManager, 27 | } 28 | } 29 | 30 | func (h *Handler) Init(cfg *config.Config) *gin.Engine { 31 | // Init gin handler 32 | router := gin.Default() 33 | 34 | router.Use( 35 | gin.Recovery(), 36 | gin.Logger(), 37 | limiter.Limit(cfg.Limiter.RPS, cfg.Limiter.Burst, cfg.Limiter.TTL), 38 | corsMiddleware, 39 | ) 40 | 41 | docs.SwaggerInfo.Host = fmt.Sprintf("%s:%s", cfg.HTTP.Host, cfg.HTTP.Port) 42 | if cfg.Environment != config.EnvLocal { 43 | docs.SwaggerInfo.Host = cfg.HTTP.Host 44 | } 45 | 46 | if cfg.Environment != config.Prod { 47 | router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) 48 | } 49 | 50 | // Init router 51 | router.GET("/ping", func(c *gin.Context) { 52 | c.String(http.StatusOK, "pong") 53 | }) 54 | 55 | h.initAPI(router) 56 | 57 | return router 58 | } 59 | 60 | func (h *Handler) initAPI(router *gin.Engine) { 61 | handlerV1 := v1.NewHandler(h.services, h.tokenManager) 62 | api := router.Group("/api") 63 | { 64 | handlerV1.Init(api) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/delivery/http/handler_test.go: -------------------------------------------------------------------------------- 1 | package http_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | "github.com/zhashkevych/creatly-backend/internal/config" 11 | handler "github.com/zhashkevych/creatly-backend/internal/delivery/http" 12 | "github.com/zhashkevych/creatly-backend/internal/service" 13 | "github.com/zhashkevych/creatly-backend/pkg/auth" 14 | ) 15 | 16 | func TestNewHandler(t *testing.T) { 17 | h := handler.NewHandler(&service.Services{}, &auth.Manager{}) 18 | 19 | require.IsType(t, &handler.Handler{}, h) 20 | } 21 | 22 | func TestNewHandler_Init(t *testing.T) { 23 | h := handler.NewHandler(&service.Services{}, &auth.Manager{}) 24 | 25 | router := h.Init(&config.Config{ 26 | Limiter: config.LimiterConfig{ 27 | RPS: 2, 28 | Burst: 4, 29 | TTL: 10 * time.Minute, 30 | }, 31 | }) 32 | 33 | ts := httptest.NewServer(router) 34 | defer ts.Close() 35 | 36 | res, err := http.Get(ts.URL + "/ping") 37 | if err != nil { 38 | t.Error(err) 39 | } 40 | 41 | require.Equal(t, http.StatusOK, res.StatusCode) 42 | } 43 | -------------------------------------------------------------------------------- /internal/delivery/http/middleware.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func corsMiddleware(c *gin.Context) { 10 | c.Header("Access-Control-Allow-Origin", "*") 11 | c.Header("Access-Control-Allow-Methods", "*") 12 | c.Header("Access-Control-Allow-Headers", "*") 13 | c.Header("Content-Type", "application/json") 14 | 15 | if c.Request.Method != "OPTIONS" { 16 | c.Next() 17 | } else { 18 | c.AbortWithStatus(http.StatusOK) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/60e69c1f4bb5a43711c0ee98-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/60e69c1f4bb5a43711c0ee98-image.png -------------------------------------------------------------------------------- /internal/delivery/http/v1/60e69c448af06211ebfaba10-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/60e69c448af06211ebfaba10-image.jpg -------------------------------------------------------------------------------- /internal/delivery/http/v1/60e69c81be7bbc1184dd21d1-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/60e69c81be7bbc1184dd21d1-image.png -------------------------------------------------------------------------------- /internal/delivery/http/v1/60e69cb986803c02115a55c5-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/60e69cb986803c02115a55c5-image.jpg -------------------------------------------------------------------------------- /internal/delivery/http/v1/60e69cb986803c02115a55c5-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/60e69cb986803c02115a55c5-image.png -------------------------------------------------------------------------------- /internal/delivery/http/v1/60e69cc243b3478089ed9f22-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/60e69cc243b3478089ed9f22-image.jpg -------------------------------------------------------------------------------- /internal/delivery/http/v1/60e69cc243b3478089ed9f22-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/60e69cc243b3478089ed9f22-image.png -------------------------------------------------------------------------------- /internal/delivery/http/v1/admins_media.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | ) 9 | 10 | type adminGetVideoResponse struct { 11 | Status domain.FileStatus `json:"status"` 12 | URL string `json:"url"` 13 | } 14 | 15 | // @Summary Get Video By ID 16 | // @Security AdminAuth 17 | // @Tags admins-media 18 | // @Description get video by id 19 | // @ModuleID adminGetVideo 20 | // @Accept json 21 | // @Produce json 22 | // @Param id path string true "video id" 23 | // @Success 200 {object} adminGetVideoResponse 24 | // @Failure 400,404 {object} response 25 | // @Failure 500 {object} response 26 | // @Failure default {object} response 27 | // @Router /admins/media/videos/{id} [get] 28 | func (h *Handler) adminGetVideo(c *gin.Context) { 29 | id, err := parseIdFromPath(c, "id") 30 | if err != nil { 31 | newResponse(c, http.StatusBadRequest, "empty id param") 32 | 33 | return 34 | } 35 | 36 | school, err := getSchoolFromContext(c) 37 | if err != nil { 38 | newResponse(c, http.StatusInternalServerError, err.Error()) 39 | 40 | return 41 | } 42 | 43 | file, err := h.services.Files.GetByID(c.Request.Context(), id, school.ID) 44 | if err != nil { 45 | newResponse(c, http.StatusInternalServerError, err.Error()) 46 | 47 | return 48 | } 49 | 50 | c.JSON(http.StatusOK, adminGetVideoResponse{ 51 | Status: file.Status, 52 | URL: file.URL, 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/admins_upload_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "mime/multipart" 9 | "net/http/httptest" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/gin-gonic/gin" 16 | "github.com/golang/mock/gomock" 17 | "github.com/stretchr/testify/assert" 18 | "github.com/zhashkevych/creatly-backend/internal/domain" 19 | "github.com/zhashkevych/creatly-backend/internal/service" 20 | mock_service "github.com/zhashkevych/creatly-backend/internal/service/mocks" 21 | "go.mongodb.org/mongo-driver/bson/primitive" 22 | ) 23 | 24 | func TestHandler_adminUploadImage(t *testing.T) { 25 | type mockBehavior func(r *mock_service.MockFiles, filepath, extension, contentType string, fileSize int64) error 26 | 27 | school := domain.School{ 28 | ID: primitive.NewObjectID(), 29 | } 30 | 31 | tests := []struct { 32 | name string 33 | filePath string 34 | contentType string 35 | extension string 36 | fileSize int64 37 | returnUrl string 38 | mockBehavior mockBehavior 39 | statusCode int 40 | responseBody string 41 | }{ 42 | { 43 | name: "Ok jpg", 44 | filePath: "./fixtures/image.jpg", 45 | fileSize: 434918, 46 | contentType: "image/jpeg", 47 | extension: "jpg", 48 | mockBehavior: func(r *mock_service.MockFiles, filepath, extension, contentType string, fileSize int64) error { 49 | file, err := os.Open(filepath) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | defer file.Close() 55 | 56 | buffer := make([]byte, fileSize) 57 | _, err = file.Read(buffer) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | r.EXPECT().UploadAndSaveFile(context.Background(), domain.File{ 63 | Type: domain.Image, 64 | Name: fmt.Sprintf("%s-image.jpg", school.ID.Hex()), 65 | ContentType: contentType, 66 | Size: fileSize, 67 | SchoolID: school.ID, 68 | }).Return("https://storage/image.jpg", nil) 69 | 70 | return nil 71 | }, 72 | statusCode: 200, 73 | responseBody: `{"url":"https://storage/image.jpg"}`, 74 | }, 75 | { 76 | name: "Ok png", 77 | filePath: "./fixtures/image.png", 78 | fileSize: 33764, 79 | contentType: "image/png", 80 | extension: "png", 81 | mockBehavior: func(r *mock_service.MockFiles, filepath, extension, contentType string, fileSize int64) error { 82 | file, err := os.Open(filepath) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | defer file.Close() 88 | 89 | buffer := make([]byte, fileSize) 90 | _, err = file.Read(buffer) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | r.EXPECT().UploadAndSaveFile(context.Background(), domain.File{ 96 | Type: domain.Image, 97 | Name: fmt.Sprintf("%s-image.png", school.ID.Hex()), 98 | ContentType: contentType, 99 | Size: fileSize, 100 | SchoolID: school.ID, 101 | }).Return("https://storage/image.png", nil) 102 | 103 | return nil 104 | }, 105 | statusCode: 200, 106 | responseBody: `{"url":"https://storage/image.png"}`, 107 | }, 108 | { 109 | name: "Image too large", 110 | filePath: "./fixtures/large.jpeg", 111 | mockBehavior: func(r *mock_service.MockFiles, filepath, extension, contentType string, fileSize int64) error { 112 | return nil 113 | }, 114 | statusCode: 400, 115 | responseBody: `{"message":"http: request body too large"}`, 116 | }, 117 | { 118 | name: "PDF upload", 119 | filePath: "./fixtures/ccc.pdf", 120 | mockBehavior: func(r *mock_service.MockFiles, filepath, extension, contentType string, fileSize int64) error { 121 | return nil 122 | }, 123 | statusCode: 400, 124 | responseBody: `{"message":"file type is not supported"}`, 125 | }, 126 | } 127 | 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | // Init Dependencies 131 | c := gomock.NewController(t) 132 | defer c.Finish() 133 | 134 | s := mock_service.NewMockFiles(c) 135 | err := tt.mockBehavior(s, tt.filePath, tt.extension, tt.contentType, tt.fileSize) 136 | assert.NoError(t, err) 137 | 138 | services := &service.Services{Files: s} 139 | handler := Handler{services: services} 140 | 141 | // Init Endpoint 142 | r := gin.New() 143 | r.POST("/upload", func(c *gin.Context) { 144 | c.Set(schoolCtx, school) 145 | }, handler.adminUploadImage) 146 | 147 | // Create Request 148 | file, err := os.Open(tt.filePath) 149 | assert.NoError(t, err) 150 | 151 | defer file.Close() 152 | 153 | body := &bytes.Buffer{} 154 | writer := multipart.NewWriter(body) 155 | part, _ := writer.CreateFormFile("file", filepath.Base(file.Name())) 156 | 157 | _, err = io.Copy(part, file) 158 | assert.NoError(t, err) 159 | 160 | err = writer.Close() 161 | assert.NoError(t, err) 162 | 163 | w := httptest.NewRecorder() 164 | req := httptest.NewRequest("POST", "/upload", body) 165 | req.Header.Add("Content-Type", writer.FormDataContentType()) 166 | 167 | // Make Request 168 | r.ServeHTTP(w, req) 169 | 170 | // Assert 171 | assert.Equal(t, w.Code, tt.statusCode) 172 | assert.Equal(t, w.Body.String(), tt.responseBody) 173 | 174 | // Remove files 175 | filenameParts := strings.Split(tt.filePath, "/") 176 | filename := fmt.Sprintf("%s-%s", school.ID.Hex(), filenameParts[len(filenameParts)-1]) 177 | os.Remove(filename) 178 | }) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/courses.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/zhashkevych/creatly-backend/internal/domain" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | func (h *Handler) initCoursesRoutes(api *gin.RouterGroup) { 13 | courses := api.Group("/courses", h.setSchoolFromRequest) 14 | { 15 | courses.GET("", h.getAllCourses) 16 | courses.GET("/:id", h.getCourseById) 17 | courses.GET("/:id/offers", h.getCourseOffers) 18 | } 19 | } 20 | 21 | // @Summary Get All Courses 22 | // @Tags courses 23 | // @Description get all courses 24 | // @ModuleID getAllCourses 25 | // @Accept json 26 | // @Produce json 27 | // @Success 200 {object} dataResponse 28 | // @Failure 400,404 {object} response 29 | // @Failure 500 {object} response 30 | // @Failure default {object} response 31 | // @Router /courses [get] 32 | func (h *Handler) getAllCourses(c *gin.Context) { 33 | school, err := getSchoolFromContext(c) 34 | if err != nil { 35 | newResponse(c, http.StatusInternalServerError, err.Error()) 36 | 37 | return 38 | } 39 | 40 | // Return only published courses 41 | courses := make([]domain.Course, 0) 42 | 43 | for _, course := range school.Courses { 44 | if course.Published { 45 | courses = append(courses, course) 46 | } 47 | } 48 | 49 | c.JSON(http.StatusOK, dataResponse{Data: courses}) 50 | } 51 | 52 | type getCourseByIdResponse struct { 53 | Course domain.Course `json:"course"` 54 | Modules []module `json:"modules"` 55 | } 56 | 57 | type module struct { 58 | ID primitive.ObjectID `json:"id" bson:"_id"` 59 | Name string `json:"name" bson:"name"` 60 | Position uint `json:"position" bson:"position"` 61 | Lessons []lesson `json:"lessons" bson:"lessons"` 62 | } 63 | 64 | type lesson struct { 65 | ID primitive.ObjectID `json:"id" bson:"_id"` 66 | Name string `json:"name" bson:"name"` 67 | Position uint `json:"position" bson:"position"` 68 | } 69 | 70 | func newGetCourseByIdResponse(course domain.Course, courseModules []domain.Module) getCourseByIdResponse { 71 | modules := make([]module, len(courseModules)) 72 | 73 | for i := range courseModules { 74 | modules[i].ID = courseModules[i].ID 75 | modules[i].Name = courseModules[i].Name 76 | modules[i].Position = courseModules[i].Position 77 | modules[i].Lessons = toLessons(courseModules[i].Lessons) 78 | } 79 | 80 | return getCourseByIdResponse{ 81 | Course: course, 82 | Modules: modules, 83 | } 84 | } 85 | 86 | func toLessons(lessons []domain.Lesson) []lesson { 87 | out := make([]lesson, 0) 88 | 89 | for _, l := range lessons { 90 | if l.Published { 91 | out = append(out, lesson{ 92 | ID: l.ID, 93 | Name: l.Name, 94 | Position: l.Position, 95 | }) 96 | } 97 | } 98 | 99 | return out 100 | } 101 | 102 | // @Summary Get Course By ModuleID 103 | // @Tags courses 104 | // @Description get course by id 105 | // @ModuleID getCourseById 106 | // @Accept json 107 | // @Produce json 108 | // @Param id path string true "course id" 109 | // @Success 200 {object} domain.Course 110 | // @Failure 400,404 {object} response 111 | // @Failure 500 {object} response 112 | // @Failure default {object} response 113 | // @Router /courses/{id} [get] 114 | func (h *Handler) getCourseById(c *gin.Context) { 115 | id := c.Param("id") 116 | if id == "" { 117 | newResponse(c, http.StatusBadRequest, "empty id param") 118 | 119 | return 120 | } 121 | 122 | school, err := getSchoolFromContext(c) 123 | if err != nil { 124 | newResponse(c, http.StatusInternalServerError, err.Error()) 125 | 126 | return 127 | } 128 | 129 | course, err := studentGetSchoolCourse(school, id) 130 | if err != nil { 131 | newResponse(c, http.StatusBadRequest, err.Error()) 132 | 133 | return 134 | } 135 | 136 | modules, err := h.services.Modules.GetPublishedByCourseId(c.Request.Context(), course.ID) 137 | if err != nil { 138 | newResponse(c, http.StatusInternalServerError, err.Error()) 139 | 140 | return 141 | } 142 | 143 | c.JSON(http.StatusOK, newGetCourseByIdResponse(course, modules)) 144 | } 145 | 146 | func studentGetSchoolCourse(school domain.School, courseId string) (domain.Course, error) { 147 | var searchedCourse domain.Course 148 | 149 | for _, course := range school.Courses { 150 | if course.Published && course.ID.Hex() == courseId { 151 | searchedCourse = course 152 | } 153 | } 154 | 155 | if searchedCourse.ID.IsZero() { 156 | return domain.Course{}, errors.New("not found") 157 | } 158 | 159 | return searchedCourse, nil 160 | } 161 | 162 | // @Summary Get Course Offers 163 | // @Tags courses 164 | // @Description get course offers 165 | // @ModuleID getCourseOffers 166 | // @Accept json 167 | // @Produce json 168 | // @Param id path string true "course id" 169 | // @Success 200 {object} dataResponse 170 | // @Failure 400,404 {object} response 171 | // @Failure 500 {object} response 172 | // @Failure default {object} response 173 | // @Router /courses/{id}/offers [get] 174 | func (h *Handler) getCourseOffers(c *gin.Context) { 175 | id := c.Param("id") 176 | if id == "" { 177 | newResponse(c, http.StatusBadRequest, "empty id param") 178 | 179 | return 180 | } 181 | 182 | courseId, err := primitive.ObjectIDFromHex(id) 183 | if err != nil { 184 | newResponse(c, http.StatusBadRequest, "invalid id param") 185 | 186 | return 187 | } 188 | 189 | offers, err := h.services.Offers.GetByCourse(c.Request.Context(), courseId) 190 | if err != nil { 191 | newResponse(c, http.StatusInternalServerError, err.Error()) 192 | 193 | return 194 | } 195 | 196 | c.JSON(http.StatusOK, dataResponse{Data: offers}) 197 | } 198 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/fixtures/ccc.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/fixtures/ccc.pdf -------------------------------------------------------------------------------- /internal/delivery/http/v1/fixtures/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/fixtures/image.jpg -------------------------------------------------------------------------------- /internal/delivery/http/v1/fixtures/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/fixtures/image.png -------------------------------------------------------------------------------- /internal/delivery/http/v1/fixtures/large.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Creatly/creatly-backend/483b7788a66b0b077e42948510c7fd0acd58eec1/internal/delivery/http/v1/fixtures/large.jpeg -------------------------------------------------------------------------------- /internal/delivery/http/v1/handler.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/zhashkevych/creatly-backend/internal/service" 8 | "github.com/zhashkevych/creatly-backend/pkg/auth" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | type Handler struct { 13 | services *service.Services 14 | tokenManager auth.TokenManager 15 | } 16 | 17 | func NewHandler(services *service.Services, tokenManager auth.TokenManager) *Handler { 18 | return &Handler{ 19 | services: services, 20 | tokenManager: tokenManager, 21 | } 22 | } 23 | 24 | func (h *Handler) Init(api *gin.RouterGroup) { 25 | v1 := api.Group("/v1") 26 | { 27 | h.initUsersRoutes(v1) 28 | h.initCoursesRoutes(v1) 29 | h.initStudentsRoutes(v1) 30 | h.initCallbackRoutes(v1) 31 | h.initAdminRoutes(v1) 32 | 33 | v1.GET("/settings", h.setSchoolFromRequest, h.getSchoolSettings) 34 | v1.GET("/promocodes/:code", h.setSchoolFromRequest, h.getPromo) 35 | v1.GET("/offers/:id", h.setSchoolFromRequest, h.getOffer) 36 | } 37 | } 38 | 39 | func parseIdFromPath(c *gin.Context, param string) (primitive.ObjectID, error) { 40 | idParam := c.Param(param) 41 | if idParam == "" { 42 | return primitive.ObjectID{}, errors.New("empty id param") 43 | } 44 | 45 | id, err := primitive.ObjectIDFromHex(idParam) 46 | if err != nil { 47 | return primitive.ObjectID{}, errors.New("invalid id param") 48 | } 49 | 50 | return id, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/middleware.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/zhashkevych/creatly-backend/internal/domain" 10 | "github.com/zhashkevych/creatly-backend/pkg/logger" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | ) 13 | 14 | const ( 15 | authorizationHeader = "Authorization" 16 | 17 | studentCtx = "studentId" 18 | adminCtx = "adminId" 19 | userCtx = "userId" 20 | schoolCtx = "school" 21 | domainCtx = "domain" 22 | ) 23 | 24 | func (h *Handler) setSchoolFromRequest(c *gin.Context) { 25 | host := parseRequestHost(c) 26 | 27 | school, err := h.services.Schools.GetByDomain(c.Request.Context(), host) 28 | if err != nil { 29 | logger.Error(err) 30 | c.AbortWithStatus(http.StatusForbidden) 31 | 32 | return 33 | } 34 | 35 | c.Set(schoolCtx, school) 36 | c.Set(domainCtx, host) 37 | } 38 | 39 | func parseRequestHost(c *gin.Context) string { 40 | refererHeader := c.Request.Header.Get("Referer") 41 | refererParts := strings.Split(refererHeader, "/") 42 | 43 | // this logic is used to avoid crashes during integration testing 44 | if len(refererParts) < 3 { 45 | return c.Request.Host 46 | } 47 | 48 | hostParts := strings.Split(refererParts[2], ":") 49 | 50 | return hostParts[0] 51 | } 52 | 53 | func getSchoolFromContext(c *gin.Context) (domain.School, error) { 54 | value, ex := c.Get(schoolCtx) 55 | if !ex { 56 | return domain.School{}, errors.New("school is missing from ctx") 57 | } 58 | 59 | school, ok := value.(domain.School) 60 | if !ok { 61 | return domain.School{}, errors.New("failed to convert value from ctx to domain.School") 62 | } 63 | 64 | return school, nil 65 | } 66 | 67 | func (h *Handler) studentIdentity(c *gin.Context) { 68 | id, err := h.parseAuthHeader(c) 69 | if err != nil { 70 | newResponse(c, http.StatusUnauthorized, err.Error()) 71 | } 72 | 73 | c.Set(studentCtx, id) 74 | } 75 | 76 | func (h *Handler) adminIdentity(c *gin.Context) { 77 | id, err := h.parseAuthHeader(c) 78 | if err != nil { 79 | newResponse(c, http.StatusUnauthorized, err.Error()) 80 | } 81 | 82 | c.Set(adminCtx, id) 83 | } 84 | 85 | func (h *Handler) userIdentity(c *gin.Context) { 86 | id, err := h.parseAuthHeader(c) 87 | if err != nil { 88 | newResponse(c, http.StatusUnauthorized, err.Error()) 89 | } 90 | 91 | c.Set(userCtx, id) 92 | } 93 | 94 | func (h *Handler) parseAuthHeader(c *gin.Context) (string, error) { 95 | header := c.GetHeader(authorizationHeader) 96 | if header == "" { 97 | return "", errors.New("empty auth header") 98 | } 99 | 100 | headerParts := strings.Split(header, " ") 101 | if len(headerParts) != 2 || headerParts[0] != "Bearer" { 102 | return "", errors.New("invalid auth header") 103 | } 104 | 105 | if len(headerParts[1]) == 0 { 106 | return "", errors.New("token is empty") 107 | } 108 | 109 | return h.tokenManager.Parse(headerParts[1]) 110 | } 111 | 112 | func getStudentId(c *gin.Context) (primitive.ObjectID, error) { 113 | return getIdByContext(c, studentCtx) 114 | } 115 | 116 | func getUserId(c *gin.Context) (primitive.ObjectID, error) { 117 | return getIdByContext(c, userCtx) 118 | } 119 | 120 | func getIdByContext(c *gin.Context, context string) (primitive.ObjectID, error) { 121 | idFromCtx, ok := c.Get(context) 122 | if !ok { 123 | return primitive.ObjectID{}, errors.New("studentCtx not found") 124 | } 125 | 126 | idStr, ok := idFromCtx.(string) 127 | if !ok { 128 | return primitive.ObjectID{}, errors.New("studentCtx is of invalid type") 129 | } 130 | 131 | id, err := primitive.ObjectIDFromHex(idStr) 132 | if err != nil { 133 | return primitive.ObjectID{}, err 134 | } 135 | 136 | return id, nil 137 | } 138 | 139 | func getDomainFromContext(c *gin.Context) (string, error) { 140 | val, ex := c.Get(domainCtx) 141 | if !ex { 142 | return "", errors.New("domainCtx not found") 143 | } 144 | 145 | valStr, ok := val.(string) 146 | if !ok { 147 | return "", errors.New("domainCtx is of invalid type") 148 | } 149 | 150 | return valStr, nil 151 | } 152 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/offer.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/zhashkevych/creatly-backend/internal/domain" 9 | ) 10 | 11 | // @Summary Get Offer By ID 12 | // @Tags offers 13 | // @Description get offer by id 14 | // @ModuleID getOffer 15 | // @Accept json 16 | // @Produce json 17 | // @Param id path string true "id" 18 | // @Success 200 {object} domain.Offer 19 | // @Failure 400,404 {object} response 20 | // @Failure 500 {object} response 21 | // @Failure default {object} response 22 | // @Router /offers/{id} [get] 23 | func (h *Handler) getOffer(c *gin.Context) { 24 | id, err := parseIdFromPath(c, "id") 25 | if err != nil { 26 | newResponse(c, http.StatusBadRequest, "invalid id param") 27 | 28 | return 29 | } 30 | 31 | offer, err := h.services.Offers.GetById(c.Request.Context(), id) 32 | if err != nil { 33 | if errors.Is(err, domain.ErrPromoNotFound) { 34 | newResponse(c, http.StatusBadRequest, err.Error()) 35 | 36 | return 37 | } 38 | 39 | newResponse(c, http.StatusInternalServerError, err.Error()) 40 | 41 | return 42 | } 43 | 44 | c.JSON(http.StatusOK, offer) 45 | } 46 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/payment.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/zhashkevych/creatly-backend/internal/domain" 9 | "github.com/zhashkevych/creatly-backend/pkg/payment/fondy" 10 | ) 11 | 12 | func (h *Handler) initCallbackRoutes(api *gin.RouterGroup) { 13 | callback := api.Group("/callback") 14 | { 15 | callback.POST("/fondy", h.handleFondyCallback) 16 | } 17 | } 18 | 19 | func (h *Handler) handleFondyCallback(c *gin.Context) { 20 | if c.Request.UserAgent() != fondy.UserAgent { 21 | newResponse(c, http.StatusForbidden, "forbidden") 22 | 23 | return 24 | } 25 | 26 | var inp fondy.Callback 27 | if err := c.BindJSON(&inp); err != nil { 28 | newResponse(c, http.StatusBadRequest, err.Error()) 29 | 30 | return 31 | } 32 | 33 | if err := h.services.Payments.ProcessTransaction(c.Request.Context(), inp); err != nil { 34 | if errors.Is(err, domain.ErrTransactionInvalid) { 35 | newResponse(c, http.StatusBadRequest, err.Error()) 36 | 37 | return 38 | } 39 | 40 | newResponse(c, http.StatusInternalServerError, err.Error()) 41 | 42 | return 43 | } 44 | 45 | c.Status(http.StatusOK) 46 | } 47 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/promocodes.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/zhashkevych/creatly-backend/internal/domain" 9 | ) 10 | 11 | // @Summary Get PromoCode By Code 12 | // @Tags promocodes 13 | // @Description get promocode by code 14 | // @ModuleID getPromo 15 | // @Accept json 16 | // @Produce json 17 | // @Param code path string true "code" 18 | // @Success 200 {object} domain.PromoCode 19 | // @Failure 400,404 {object} response 20 | // @Failure 500 {object} response 21 | // @Failure default {object} response 22 | // @Router /promocodes/{code} [get] 23 | func (h *Handler) getPromo(c *gin.Context) { 24 | code := c.Param("code") 25 | if code == "" { 26 | newResponse(c, http.StatusBadRequest, "empty code param") 27 | 28 | return 29 | } 30 | 31 | school, err := getSchoolFromContext(c) 32 | if err != nil { 33 | newResponse(c, http.StatusInternalServerError, err.Error()) 34 | 35 | return 36 | } 37 | 38 | promocode, err := h.services.PromoCodes.GetByCode(c.Request.Context(), school.ID, code) 39 | if err != nil { 40 | if errors.Is(err, domain.ErrPromoNotFound) { 41 | newResponse(c, http.StatusBadRequest, err.Error()) 42 | 43 | return 44 | } 45 | 46 | newResponse(c, http.StatusInternalServerError, err.Error()) 47 | 48 | return 49 | } 50 | 51 | c.JSON(http.StatusOK, promocode) 52 | } 53 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/promocodes_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/golang/mock/gomock" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/zhashkevych/creatly-backend/internal/domain" 15 | "github.com/zhashkevych/creatly-backend/internal/service" 16 | mock_service "github.com/zhashkevych/creatly-backend/internal/service/mocks" 17 | "go.mongodb.org/mongo-driver/bson/primitive" 18 | ) 19 | 20 | func TestHandler_getPromocode(t *testing.T) { 21 | type mockBehavior func(r *mock_service.MockPromoCodes, schoolId primitive.ObjectID, code string, promocode domain.PromoCode) 22 | 23 | schoolId := primitive.NewObjectID() 24 | 25 | promocode := domain.PromoCode{ 26 | Code: "GOGOGO25", 27 | DiscountPercentage: 25, 28 | } 29 | 30 | setResponseBody := func(promocode domain.PromoCode) string { 31 | body, _ := json.Marshal(promocode) 32 | 33 | return string(body) 34 | } 35 | 36 | tests := []struct { 37 | name string 38 | code string 39 | schoolId primitive.ObjectID 40 | promocode domain.PromoCode 41 | mockBehavior mockBehavior 42 | statusCode int 43 | responseBody string 44 | }{ 45 | { 46 | name: "ok", 47 | code: "GOGOGO25", 48 | schoolId: schoolId, 49 | promocode: promocode, 50 | mockBehavior: func(r *mock_service.MockPromoCodes, schoolId primitive.ObjectID, code string, promocode domain.PromoCode) { 51 | r.EXPECT().GetByCode(context.Background(), schoolId, code).Return(promocode, nil) 52 | }, 53 | statusCode: 200, 54 | responseBody: setResponseBody(promocode), 55 | }, 56 | { 57 | name: "empty code", 58 | code: "", 59 | schoolId: schoolId, 60 | promocode: promocode, 61 | mockBehavior: func(r *mock_service.MockPromoCodes, schoolId primitive.ObjectID, code string, promocode domain.PromoCode) { 62 | }, 63 | statusCode: 404, 64 | responseBody: `404 page not found`, 65 | }, 66 | { 67 | name: "service error", 68 | code: "GOGOGO25", 69 | schoolId: schoolId, 70 | promocode: promocode, 71 | mockBehavior: func(r *mock_service.MockPromoCodes, schoolId primitive.ObjectID, code string, promocode domain.PromoCode) { 72 | r.EXPECT().GetByCode(context.Background(), schoolId, code).Return(promocode, errors.New("failed to get promocode")) 73 | }, 74 | statusCode: 500, 75 | responseBody: `{"message":"failed to get promocode"}`, 76 | }, 77 | } 78 | 79 | for _, tt := range tests { 80 | t.Run(tt.name, func(t *testing.T) { 81 | // Init Dependencies 82 | c := gomock.NewController(t) 83 | defer c.Finish() 84 | 85 | s := mock_service.NewMockPromoCodes(c) 86 | tt.mockBehavior(s, tt.schoolId, tt.code, tt.promocode) 87 | 88 | services := &service.Services{PromoCodes: s} 89 | handler := Handler{services: services} 90 | 91 | // Init Endpoint 92 | r := gin.New() 93 | r.GET("/promocodes/:code", func(c *gin.Context) { 94 | c.Set(schoolCtx, domain.School{ 95 | ID: schoolId, 96 | }) 97 | }, handler.getPromo) 98 | 99 | // Create Request 100 | w := httptest.NewRecorder() 101 | req := httptest.NewRequest("GET", fmt.Sprintf("/promocodes/%s", tt.code), nil) 102 | 103 | // Make Request 104 | r.ServeHTTP(w, req) 105 | 106 | // Assert 107 | assert.Equal(t, tt.statusCode, w.Code) 108 | assert.Equal(t, w.Body.String(), tt.responseBody) 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/response.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/zhashkevych/creatly-backend/pkg/logger" 6 | ) 7 | 8 | type dataResponse struct { 9 | Data interface{} `json:"data"` 10 | Count int64 `json:"count"` 11 | } 12 | 13 | type idResponse struct { 14 | ID interface{} `json:"id"` 15 | } 16 | 17 | type response struct { 18 | Message string `json:"message"` 19 | } 20 | 21 | func newResponse(c *gin.Context, statusCode int, message string) { 22 | logger.Error(message) 23 | c.AbortWithStatusJSON(statusCode, response{message}) 24 | } 25 | -------------------------------------------------------------------------------- /internal/delivery/http/v1/settings.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/domain" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | type schoolSettingsResponse struct { 12 | Name string `json:"name"` 13 | Subtitle string `json:"subtitle"` 14 | Description string `json:"description"` 15 | Settings domain.Settings `json:"settings"` 16 | } 17 | 18 | // @Summary School GetSettings 19 | // @Tags school-settings 20 | // @Description school get settings 21 | // @ModuleID getSchoolSettings 22 | // @Produce json 23 | // @Success 200 {object} schoolSettingsResponse 24 | // @Failure 400,404 {object} response 25 | // @Failure 500 {object} response 26 | // @Failure default {object} response 27 | // @Router /settings [get] 28 | func (h *Handler) getSchoolSettings(c *gin.Context) { 29 | school, err := getSchoolFromContext(c) 30 | if err != nil { 31 | newResponse(c, http.StatusInternalServerError, err.Error()) 32 | 33 | return 34 | } 35 | 36 | c.JSON(http.StatusOK, schoolSettingsResponse{ 37 | Name: school.Name, 38 | Subtitle: school.Subtitle, 39 | Description: school.Description, 40 | Settings: school.Settings, 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /internal/domain/course.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type Course struct { 10 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 11 | Name string `json:"name" bson:"name,omitempty"` 12 | Code string `json:"code" bson:"code,omitempty"` 13 | Description string `json:"description" bson:"description,omitempty"` 14 | Color string `json:"color" bson:"color,omitempty"` 15 | ImageURL string `json:"imageUrl" bson:"imageUrl,omitempty"` 16 | CreatedAt time.Time `json:"createdAt" bson:"createdAt,omitempty"` 17 | UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` 18 | Published bool `json:"published" bson:"published,omitempty"` 19 | } 20 | 21 | type Module struct { 22 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 23 | Name string `json:"name" bson:"name"` 24 | Position uint `json:"position" bson:"position"` 25 | Published bool `json:"published"` 26 | CourseID primitive.ObjectID `json:"courseId" bson:"courseId"` 27 | PackageID primitive.ObjectID `json:"packageId,omitempty" bson:"packageId,omitempty"` 28 | SchoolID primitive.ObjectID `json:"schoolId" bson:"schoolId"` 29 | Lessons []Lesson `json:"lessons,omitempty" bson:"lessons,omitempty"` 30 | Survey Survey `json:"survey,omitempty" bson:"survey,omitempty"` 31 | } 32 | 33 | type Lesson struct { 34 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 35 | Name string `json:"name" bson:"name"` 36 | Position uint `json:"position" bson:"position"` 37 | Published bool `json:"published" bson:"published,omitempty"` 38 | Content string `json:"content,omitempty" bson:"content,omitempty"` 39 | SchoolID primitive.ObjectID `json:"schoolId" bson:"schoolId"` 40 | } 41 | 42 | type LessonContent struct { 43 | LessonID primitive.ObjectID `json:"lessonId" bson:"lessonId"` 44 | SchoolID primitive.ObjectID `json:"schoolId" bson:"schoolId"` 45 | Content string `json:"content" bson:"content"` 46 | } 47 | 48 | type Package struct { 49 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 50 | Name string `json:"name" bson:"name"` 51 | CourseID primitive.ObjectID `json:"courseId" bson:"courseId"` 52 | SchoolID primitive.ObjectID `json:"schoolId" bson:"schoolId"` 53 | Modules []Module `json:"modules" bson:"-"` 54 | } 55 | 56 | type ModuleContent struct { 57 | Lessons []Lesson `json:"lessons" bson:"lessons"` 58 | Survey Survey `json:"survey" bson:"survey"` 59 | } 60 | -------------------------------------------------------------------------------- /internal/domain/errors.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrUserNotFound = errors.New("user doesn't exists") 7 | ErrVerificationCodeInvalid = errors.New("verification code is invalid") 8 | ErrOfferNotFound = errors.New("offer doesn't exists") 9 | ErrPromoNotFound = errors.New("promocode doesn't exists") 10 | ErrCourseNotFound = errors.New("course not found") 11 | ErrUserAlreadyExists = errors.New("user with such email already exists") 12 | ErrModuleIsNotAvailable = errors.New("module's content is not available") 13 | ErrPromocodeExpired = errors.New("promocode has expired") 14 | ErrTransactionInvalid = errors.New("transaction is invalid") 15 | ErrUnknownCallbackType = errors.New("unknown callback type") 16 | ErrSendPulseIsNotConnected = errors.New("sendpulse is not connected") 17 | ErrStudentBlocked = errors.New("student is blocked by the admin") 18 | ) 19 | -------------------------------------------------------------------------------- /internal/domain/file.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type ( 10 | FileStatus int 11 | FileType string 12 | ) 13 | 14 | const ( 15 | ClientUploadInProgress FileStatus = iota 16 | UploadedByClient 17 | ClientUploadError 18 | StorageUploadInProgress 19 | UploadedToStorage 20 | StorageUploadError 21 | ) 22 | 23 | const ( 24 | Image FileType = "image" 25 | Video FileType = "video" 26 | Other FileType = "other" 27 | ) 28 | 29 | type File struct { 30 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 31 | SchoolID primitive.ObjectID `json:"schoolId" bson:"schoolId"` 32 | Type FileType `json:"type" bson:"type"` 33 | ContentType string `json:"contentType" bson:"contentType"` 34 | Name string `json:"name" bson:"name"` 35 | Size int64 `json:"size" bson:"size"` 36 | Status FileStatus `json:"status" bson:"status,omitempty"` 37 | UploadStartedAt time.Time `json:"uploadStartedAt" bson:"uploadStartedAt"` 38 | URL string `json:"url" bson:"url,omitempty"` 39 | } 40 | -------------------------------------------------------------------------------- /internal/domain/offer.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "errors" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | const ( 10 | PaymentProviderFondy = "fondy" 11 | ) 12 | 13 | var ( 14 | ErrPaymentProviderNotUsed = errors.New("payment provider is disabled for current offer") 15 | ErrUnknownPaymentProvider = errors.New("payment provider is not supported") 16 | ) 17 | 18 | type Offer struct { 19 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 20 | Name string `json:"name" bson:"name"` 21 | Description string `json:"description" bson:"description,omitempty"` 22 | Benefits []string `json:"benefits" bson:"benefits,omitempty"` 23 | SchoolID primitive.ObjectID `json:"schoolId" bson:"schoolId"` 24 | PackageIDs []primitive.ObjectID `json:"packages" bson:"packages,omitempty"` 25 | Price Price `json:"price" bson:"price"` 26 | PaymentMethod PaymentMethod `json:"paymentMethod" bson:"paymentMethod"` 27 | } 28 | 29 | type Price struct { 30 | Value uint `json:"value" bson:"value"` 31 | Currency string `json:"currency" bson:"currency"` 32 | } 33 | 34 | type PaymentMethod struct { 35 | UsesProvider bool `json:"usesProvider" bson:"usesProvider"` 36 | Provider string `json:"provider" bson:"provider,omitempty"` 37 | } 38 | 39 | func (pm PaymentMethod) Validate() error { 40 | switch pm.Provider { 41 | case PaymentProviderFondy: 42 | return nil 43 | default: 44 | return errors.New("unknown payment provider") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/domain/order.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | const ( 10 | OrderStatusCreated = "created" 11 | OrderStatusPaid = "paid" 12 | OrderStatusFailed = "failed" 13 | OrderStatusCanceled = "canceled" 14 | OrderStatusOther = "other" 15 | ) 16 | 17 | type Order struct { 18 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 19 | SchoolID primitive.ObjectID `json:"schoolId" bson:"schoolId"` 20 | Student StudentInfoShort `json:"student" bson:"student"` 21 | Offer OrderOfferInfo `json:"offer" bson:"offer"` 22 | Promo OrderPromoInfo `json:"promo" bson:"promo,omitempty"` 23 | CreatedAt time.Time `json:"createdAt" bson:"createdAt"` 24 | Amount uint `json:"amount" bson:"amount"` 25 | Currency string `json:"currency" bson:"currency"` 26 | Status string `json:"status" bson:"status"` 27 | Transactions []Transaction `json:"transactions" bson:"transactions,omitempty"` 28 | } 29 | 30 | type OrderOfferInfo struct { 31 | ID primitive.ObjectID `json:"id" bson:"id"` 32 | Name string `json:"name" bson:"name"` 33 | } 34 | 35 | type OrderPromoInfo struct { 36 | ID primitive.ObjectID `json:"id" bson:"id"` 37 | Code string `json:"code" bson:"code"` 38 | } 39 | 40 | type Transaction struct { 41 | Status string `json:"status" bson:"status"` 42 | CreatedAt time.Time `json:"createdAt" bson:"createdAt"` 43 | AdditionalInfo string `json:"additionalInfo" bson:"additionalInfo"` 44 | } 45 | -------------------------------------------------------------------------------- /internal/domain/promocode.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type PromoCode struct { 10 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 11 | SchoolID primitive.ObjectID `json:"schoolId" bson:"schoolId"` 12 | Code string `json:"code" bson:"code"` 13 | DiscountPercentage int `json:"discountPercentage" bson:"discountPercentage"` 14 | ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"` 15 | OfferIDs []primitive.ObjectID `json:"offerIds" bson:"offerIds"` 16 | } 17 | 18 | type UpdatePromoCodeInput struct { 19 | ID primitive.ObjectID 20 | SchoolID primitive.ObjectID 21 | Code string 22 | DiscountPercentage int 23 | ExpiresAt time.Time 24 | OfferIDs []primitive.ObjectID 25 | } 26 | -------------------------------------------------------------------------------- /internal/domain/query.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type PaginationQuery struct { 4 | Skip int64 `form:"skip"` 5 | Limit int64 `form:"limit"` 6 | } 7 | 8 | type SearchQuery struct { 9 | Search string `form:"search"` 10 | } 11 | 12 | type StudentFiltersQuery struct { 13 | RegisterDateFrom string `form:"registerDateFrom"` 14 | RegisterDateTo string `form:"registerDateTo"` 15 | LastVisitDateFrom string `form:"lastVisitDateFrom"` 16 | LastVisitDateTo string `form:"lastVisitDateTo"` 17 | Verified *bool `form:"verified"` 18 | } 19 | 20 | type GetStudentsQuery struct { 21 | PaginationQuery 22 | SearchQuery 23 | StudentFiltersQuery 24 | } 25 | 26 | type OrdersFiltersQuery struct { 27 | DateFrom string `form:"dateFrom"` 28 | DateTo string `form:"dateTo"` 29 | Status string `form:"status"` 30 | } 31 | 32 | type GetOrdersQuery struct { 33 | PaginationQuery 34 | SearchQuery 35 | OrdersFiltersQuery 36 | } 37 | 38 | func (p PaginationQuery) GetSkip() *int64 { 39 | if p.Skip == 0 { 40 | return nil 41 | } 42 | 43 | return &p.Skip 44 | } 45 | 46 | func (p PaginationQuery) GetLimit() *int64 { 47 | if p.Limit == 0 { 48 | return nil 49 | } 50 | 51 | return &p.Limit 52 | } 53 | -------------------------------------------------------------------------------- /internal/domain/school.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | ) 9 | 10 | var ErrFondyIsNotConnected = errors.New("fondy is not connected") 11 | 12 | type School struct { 13 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 14 | Name string `json:"name" bson:"name"` 15 | Subtitle string `json:"subtitle" bson:"subtitle,omitempty"` 16 | Description string `json:"description" bson:"description,omitempty"` 17 | RegisteredAt time.Time `json:"registeredAt" bson:"registeredAtm,omitempty"` 18 | Admins []Admin `json:"admins" bson:"admins,omitempty"` 19 | Courses []Course `json:"courses" bson:"courses,omitempty"` 20 | Settings Settings `json:"settings" bson:"settings,omitempty"` 21 | } 22 | 23 | type Settings struct { 24 | Color string `json:"color" bson:"color,omitempty"` 25 | Domains []string `json:"domains" bson:"domains,omitempty"` 26 | ContactInfo ContactInfo `json:"contactInfo" bson:"contactInfo,omitempty"` 27 | Pages Pages `json:"pages" bson:"pages,omitempty"` 28 | ShowPaymentImages bool `json:"showPaymentImages" bson:"showPaymentImages,omitempty"` 29 | Logo string `json:"logo" bson:"logo,omitempty"` 30 | GoogleAnalyticsCode string `json:"googleAnalyticsCode" bson:"googleAnalyticsCode,omitempty"` 31 | Fondy Fondy `json:"fondy" bson:"fondy,omitempty"` 32 | SendPulse SendPulse `json:"sendpulse" bson:"sendpulse,omitempty"` 33 | DisableRegistration bool `json:"disableRegistration" bson:"disableRegistration,omitempty"` 34 | } 35 | 36 | func (s Settings) GetDomain() string { 37 | return s.Domains[0] 38 | } 39 | 40 | type Fondy struct { 41 | MerchantID string `json:"merchantId" bson:"merchantId"` 42 | MerchantPassword string `json:"merchantPassword" bson:"merchantPassword"` 43 | Connected bool `json:"connected" bson:"connected"` 44 | } 45 | 46 | type SendPulse struct { 47 | ID string `json:"id" bson:"id"` 48 | Secret string `json:"secret" bson:"secret"` 49 | ListID string `json:"listId" bson:"listId"` 50 | Connected bool `json:"connected" bson:"connected"` 51 | } 52 | 53 | type ContactInfo struct { 54 | BusinessName string `json:"businessName" bson:"businessName,omitempty"` 55 | RegistrationNumber string `json:"registrationNumber" bson:"registrationNumber,omitempty"` 56 | Address string `json:"address" bson:"address,omitempty"` 57 | Email string `json:"email" bson:"email,omitempty"` 58 | Phone string `json:"phone" bson:"phone,omitempty"` 59 | } 60 | 61 | type Pages struct { 62 | Confidential string `json:"confidential" bson:"confidential,omitempty"` 63 | ServiceAgreement string `json:"serviceAgreement" bson:"serviceAgreement,omitempty"` 64 | NewsletterConsent string `json:"newsletterConsent" bson:"newsletterConsent,omitempty"` 65 | } 66 | 67 | type Admin struct { 68 | ID primitive.ObjectID `json:"id" bson:"_id"` 69 | Name string `json:"name" bson:"name"` 70 | Email string `json:"email" bson:"email"` 71 | Password string `json:"password" bson:"password"` 72 | SchoolID primitive.ObjectID 73 | } 74 | 75 | type UpdateSchoolSettingsInput struct { 76 | Name *string 77 | Color *string 78 | Domains []string 79 | Email *string 80 | ContactInfo *UpdateSchoolSettingsContactInfo 81 | Pages *UpdateSchoolSettingsPages 82 | ShowPaymentImages *bool 83 | DisableRegistration *bool 84 | GoogleAnalyticsCode *string 85 | LogoURL *string 86 | } 87 | 88 | type UpdateSchoolSettingsPages struct { 89 | Confidential *string 90 | ServiceAgreement *string 91 | NewsletterConsent *string 92 | } 93 | 94 | type UpdateSchoolSettingsContactInfo struct { 95 | BusinessName *string 96 | RegistrationNumber *string 97 | Address *string 98 | Email *string 99 | Phone *string 100 | } 101 | -------------------------------------------------------------------------------- /internal/domain/session.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "time" 4 | 5 | type Session struct { 6 | RefreshToken string `json:"refreshToken" bson:"refreshToken"` 7 | ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"` 8 | } 9 | -------------------------------------------------------------------------------- /internal/domain/student.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type Student struct { 10 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 11 | Name string `json:"name" bson:"name"` 12 | Email string `json:"email" bson:"email"` 13 | Password string `json:"password" bson:"password"` 14 | RegisteredAt time.Time `json:"registeredAt" bson:"registeredAt"` 15 | LastVisitAt time.Time `json:"lastVisitAt" bson:"lastVisitAt"` 16 | SchoolID primitive.ObjectID `json:"schoolId" bson:"schoolId"` 17 | AvailableModules []primitive.ObjectID `json:"availableModules" bson:"availableModules,omitempty"` 18 | AvailableCourses []primitive.ObjectID `json:"availableCourses" bson:"availableCourses,omitempty"` 19 | AvailableOffers []primitive.ObjectID `json:"availableOffers" bson:"availableOffers,omitempty"` 20 | Verification Verification `json:"verification" bson:"verification"` 21 | Session Session `json:"session" bson:"session,omitempty"` 22 | Blocked bool `json:"blocked" bson:"blocked"` 23 | } 24 | 25 | func (s Student) IsModuleAvailable(m Module) bool { 26 | for _, id := range s.AvailableModules { 27 | if m.ID == id { 28 | return true 29 | } 30 | } 31 | 32 | return false 33 | } 34 | 35 | type Verification struct { 36 | Code string `json:"code" bson:"code"` 37 | Verified bool `json:"verified" bson:"verified"` 38 | } 39 | 40 | type StudentLessons struct { 41 | StudentID primitive.ObjectID `json:"studentId" bson:"studentId"` 42 | Finished []primitive.ObjectID `json:"finished" bson:"finished"` 43 | LastOpened primitive.ObjectID `json:"lastOpened" bson:"lastOpened"` 44 | } 45 | 46 | type StudentInfoShort struct { 47 | ID primitive.ObjectID `json:"id" bson:"id"` 48 | Name string `json:"name" bson:"name"` 49 | Email string `json:"email" bson:"email"` 50 | } 51 | 52 | type UpdateStudentInput struct { 53 | Name string `json:"name"` 54 | Email string `json:"email"` 55 | Verified *bool `json:"verified"` 56 | Blocked *bool `json:"blocked"` 57 | StudentID primitive.ObjectID `json:"-"` 58 | SchoolID primitive.ObjectID `json:"-"` 59 | } 60 | 61 | type CreateStudentInput struct { 62 | Name string `json:"name" binding:"required,min=2"` 63 | Email string `json:"email" binding:"required,email"` 64 | Password string `json:"password" binding:"required,min=6"` 65 | SchoolID primitive.ObjectID `json:"-"` 66 | } 67 | -------------------------------------------------------------------------------- /internal/domain/survey.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type Survey struct { 10 | Title string `json:"title" bson:"title"` 11 | Questions []SurveyQuestion `json:"questions" bson:"questions"` 12 | Required bool `json:"required" bson:"required"` 13 | } 14 | 15 | type SurveyQuestion struct { 16 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 17 | Question string `json:"question" bson:"question"` 18 | AnswerType string `json:"answerType" bson:"answerType"` 19 | AnswerOptions []string `json:"answerOptions" bson:"answerOptions,omitempty"` 20 | } 21 | 22 | type SurveyResult struct { 23 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 24 | Student StudentInfoShort `json:"student" bson:"student"` 25 | ModuleID primitive.ObjectID `json:"moduleId" bson:"moduleId"` 26 | SubmittedAt time.Time `json:"submittedAt" bson:"submittedAt"` 27 | Answers []SurveyAnswer `json:"answers" bson:"answers"` 28 | } 29 | 30 | type SurveyAnswer struct { 31 | QuestionID primitive.ObjectID `json:"questionId" bson:"questionId"` 32 | Answer string `json:"answer" bson:"answer"` 33 | } 34 | -------------------------------------------------------------------------------- /internal/domain/user.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type User struct { 10 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 11 | Name string `json:"name" bson:"name"` 12 | Email string `json:"email" bson:"email"` 13 | Phone string `json:"phone" bson:"phone"` 14 | Password string `json:"password" bson:"password"` 15 | RegisteredAt time.Time `json:"registeredAt" bson:"registeredAt"` 16 | LastVisitAt time.Time `json:"lastVisitAt" bson:"lastVisitAt"` 17 | Verification Verification `json:"verification" bson:"verification"` 18 | Schools []primitive.ObjectID `json:"schools" bson:"schools"` 19 | } 20 | -------------------------------------------------------------------------------- /internal/repository/admins_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | ) 12 | 13 | type AdminsRepo struct { 14 | db *mongo.Collection 15 | } 16 | 17 | func NewAdminsRepo(db *mongo.Database) *AdminsRepo { 18 | return &AdminsRepo{db: db.Collection(adminsCollection)} 19 | } 20 | 21 | func (r *AdminsRepo) GetByCredentials(ctx context.Context, schoolId primitive.ObjectID, email, password string) (domain.Admin, error) { 22 | var admin domain.Admin 23 | err := r.db.FindOne(ctx, bson.M{"schoolId": schoolId, "email": email, "password": password}).Decode(&admin) 24 | 25 | return admin, err 26 | } 27 | 28 | func (r *AdminsRepo) GetByRefreshToken(ctx context.Context, schoolId primitive.ObjectID, refreshToken string) (domain.Admin, error) { 29 | var admin domain.Admin 30 | err := r.db.FindOne(ctx, bson.M{ 31 | "session.refreshToken": refreshToken, "schoolId": schoolId, 32 | "session.expiresAt": bson.M{"$gt": time.Now()}, 33 | }).Decode(&admin) 34 | 35 | return admin, err 36 | } 37 | 38 | func (r *AdminsRepo) SetSession(ctx context.Context, id primitive.ObjectID, session domain.Session) error { 39 | _, err := r.db.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": bson.M{"session": session}}) 40 | 41 | return err 42 | } 43 | 44 | func (r *AdminsRepo) GetById(ctx context.Context, id primitive.ObjectID) (domain.Admin, error) { 45 | var admin domain.Admin 46 | 47 | err := r.db.FindOne(ctx, bson.M{"_id": id}).Decode(&admin) 48 | 49 | return admin, err 50 | } 51 | -------------------------------------------------------------------------------- /internal/repository/collections.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | const ( 4 | adminsCollection = "admins" 5 | studentsCollection = "students" 6 | studentLessonsCollection = "studentLessons" 7 | schoolsCollection = "schools" 8 | promocodesCollection = "promocodes" 9 | offersCollection = "offers" 10 | packagesCollection = "packages" 11 | modulesCollection = "modules" 12 | contentCollection = "content" 13 | ordersCollection = "orders" 14 | usersCollection = "users" 15 | filesCollection = "files" 16 | surveyResultsCollection = "surveyResults" 17 | ) 18 | -------------------------------------------------------------------------------- /internal/repository/courses_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | ) 12 | 13 | type CoursesRepo struct { 14 | db *mongo.Collection 15 | } 16 | 17 | func NewCoursesRepo(db *mongo.Database) *CoursesRepo { 18 | return &CoursesRepo{db: db.Collection(schoolsCollection)} 19 | } 20 | 21 | func (r *CoursesRepo) Create(ctx context.Context, schoolId primitive.ObjectID, course domain.Course) (primitive.ObjectID, error) { 22 | course.ID = primitive.NewObjectID() 23 | _, err := r.db.UpdateOne(ctx, bson.M{"_id": schoolId}, bson.M{"$push": bson.M{"courses": course}}) 24 | 25 | return course.ID, err 26 | } 27 | 28 | func (r *CoursesRepo) Update(ctx context.Context, inp UpdateCourseInput) error { 29 | updateQuery := bson.M{} 30 | 31 | updateQuery["courses.$.updatedAt"] = time.Now() 32 | 33 | if inp.Name != nil { 34 | updateQuery["courses.$.name"] = *inp.Name 35 | } 36 | 37 | if inp.Description != nil { 38 | updateQuery["courses.$.description"] = *inp.Description 39 | } 40 | 41 | if inp.ImageURL != nil { 42 | updateQuery["courses.$.imageUrl"] = *inp.ImageURL 43 | } 44 | 45 | if inp.Color != nil { 46 | updateQuery["courses.$.color"] = *inp.Color 47 | } 48 | 49 | if inp.Published != nil { 50 | updateQuery["courses.$.published"] = *inp.Published 51 | } 52 | 53 | _, err := r.db.UpdateOne(ctx, 54 | bson.M{"_id": inp.SchoolID, "courses._id": inp.ID}, bson.M{"$set": updateQuery}) 55 | 56 | return err 57 | } 58 | 59 | func (r *CoursesRepo) Delete(ctx context.Context, schoolId, courseId primitive.ObjectID) error { 60 | res, err := r.db.UpdateOne(ctx, bson.M{"_id": schoolId}, bson.M{"$pull": bson.M{"courses": bson.M{"_id": courseId}}}) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | if res.ModifiedCount == 0 { 66 | return domain.ErrCourseNotFound 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/repository/files.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/domain" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "go.mongodb.org/mongo-driver/mongo" 10 | ) 11 | 12 | type FilesRepo struct { 13 | db *mongo.Collection 14 | } 15 | 16 | func NewFilesRepo(db *mongo.Database) *FilesRepo { 17 | return &FilesRepo{ 18 | db: db.Collection(filesCollection), 19 | } 20 | } 21 | 22 | func (r *FilesRepo) Create(ctx context.Context, file domain.File) (primitive.ObjectID, error) { 23 | res, err := r.db.InsertOne(ctx, file) 24 | if err != nil { 25 | return primitive.ObjectID{}, err 26 | } 27 | 28 | return res.InsertedID.(primitive.ObjectID), nil 29 | } 30 | 31 | func (r *FilesRepo) UpdateStatus(ctx context.Context, fileName string, status domain.FileStatus) error { 32 | _, err := r.db.UpdateOne(ctx, bson.M{"name": fileName}, bson.M{"$set": bson.M{"status": status}}) 33 | 34 | return err 35 | } 36 | 37 | func (r *FilesRepo) GetForUploading(ctx context.Context) (domain.File, error) { 38 | var file domain.File 39 | 40 | res := r.db.FindOneAndUpdate(ctx, bson.M{"status": domain.UploadedByClient}, bson.M{"$set": bson.M{"status": domain.StorageUploadInProgress}}) 41 | err := res.Decode(&file) 42 | 43 | return file, err 44 | } 45 | 46 | func (r *FilesRepo) UpdateStatusAndSetURL(ctx context.Context, id primitive.ObjectID, url string) error { 47 | _, err := r.db.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": bson.M{"url": url, "status": domain.UploadedToStorage}}) 48 | 49 | return err 50 | } 51 | 52 | func (r *FilesRepo) GetByID(ctx context.Context, id, schoolId primitive.ObjectID) (domain.File, error) { 53 | var file domain.File 54 | 55 | res := r.db.FindOne(ctx, bson.M{"_id": id, "schoolId": schoolId}) 56 | err := res.Decode(&file) 57 | 58 | return file, err 59 | } 60 | -------------------------------------------------------------------------------- /internal/repository/lessons_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/domain" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "go.mongodb.org/mongo-driver/mongo" 10 | "go.mongodb.org/mongo-driver/mongo/options" 11 | ) 12 | 13 | type LessonContentRepo struct { 14 | db *mongo.Collection 15 | } 16 | 17 | func NewLessonContentRepo(db *mongo.Database) *LessonContentRepo { 18 | return &LessonContentRepo{db: db.Collection(contentCollection)} 19 | } 20 | 21 | func (r *LessonContentRepo) GetByLessons(ctx context.Context, lessonIds []primitive.ObjectID) ([]domain.LessonContent, error) { 22 | var content []domain.LessonContent 23 | 24 | cur, err := r.db.Find(ctx, bson.M{"lessonId": bson.M{"$in": lessonIds}}) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | if err := cur.All(ctx, &content); err != nil { 30 | return nil, err 31 | } 32 | 33 | return content, nil 34 | } 35 | 36 | func (r *LessonContentRepo) GetByLesson(ctx context.Context, lessonId primitive.ObjectID) (domain.LessonContent, error) { 37 | var content domain.LessonContent 38 | err := r.db.FindOne(ctx, bson.M{"lessonId": lessonId}).Decode(&content) 39 | 40 | return content, err 41 | } 42 | 43 | func (r *LessonContentRepo) Update(ctx context.Context, schoolId, lessonId primitive.ObjectID, content string) error { 44 | opts := &options.UpdateOptions{} 45 | opts.SetUpsert(true) 46 | 47 | _, err := r.db.UpdateOne(ctx, bson.M{"lessonId": lessonId, "schoolId": schoolId}, bson.M{"$set": bson.M{"content": content}}, opts) 48 | 49 | return err 50 | } 51 | 52 | func (r *LessonContentRepo) DeleteContent(ctx context.Context, schoolId primitive.ObjectID, lessonIds []primitive.ObjectID) error { 53 | _, err := r.db.DeleteMany(ctx, bson.M{"lessonId": bson.M{"$in": lessonIds}, "schoolId": schoolId}) 54 | 55 | return err 56 | } 57 | -------------------------------------------------------------------------------- /internal/repository/offers_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | ) 12 | 13 | type OffersRepo struct { 14 | db *mongo.Collection 15 | } 16 | 17 | func NewOffersRepo(db *mongo.Database) *OffersRepo { 18 | return &OffersRepo{ 19 | db: db.Collection(offersCollection), 20 | } 21 | } 22 | 23 | func (r *OffersRepo) GetBySchool(ctx context.Context, schoolId primitive.ObjectID) ([]domain.Offer, error) { 24 | cur, err := r.db.Find(ctx, bson.M{"schoolId": schoolId}) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | var offers []domain.Offer 30 | err = cur.All(ctx, &offers) 31 | 32 | return offers, err 33 | } 34 | 35 | func (r *OffersRepo) GetById(ctx context.Context, id primitive.ObjectID) (domain.Offer, error) { 36 | var offer domain.Offer 37 | if err := r.db.FindOne(ctx, bson.M{"_id": id}).Decode(&offer); err != nil { 38 | if errors.Is(err, mongo.ErrNoDocuments) { 39 | return domain.Offer{}, domain.ErrOfferNotFound 40 | } 41 | 42 | return domain.Offer{}, err 43 | } 44 | 45 | return offer, nil 46 | } 47 | 48 | func (r *OffersRepo) GetByPackages(ctx context.Context, packageIds []primitive.ObjectID) ([]domain.Offer, error) { 49 | cur, err := r.db.Find(ctx, bson.M{"packages": bson.M{"$in": packageIds}}) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | var offers []domain.Offer 55 | err = cur.All(ctx, &offers) 56 | 57 | return offers, err 58 | } 59 | 60 | func (r *OffersRepo) Create(ctx context.Context, offer domain.Offer) (primitive.ObjectID, error) { 61 | res, err := r.db.InsertOne(ctx, offer) 62 | if err != nil { 63 | return primitive.ObjectID{}, err 64 | } 65 | 66 | return res.InsertedID.(primitive.ObjectID), nil 67 | } 68 | 69 | func (r *OffersRepo) Update(ctx context.Context, inp UpdateOfferInput) error { 70 | updateQuery := bson.M{} 71 | 72 | if inp.Name != "" { 73 | updateQuery["name"] = inp.Name 74 | } 75 | 76 | if inp.Description != "" { 77 | updateQuery["description"] = inp.Description 78 | } 79 | 80 | if inp.Benefits != nil { 81 | updateQuery["benefits"] = inp.Benefits 82 | } 83 | 84 | if inp.Price != nil { 85 | updateQuery["price"] = inp.Price 86 | } 87 | 88 | if inp.Packages != nil { 89 | updateQuery["packages"] = inp.Packages 90 | } 91 | 92 | if inp.PaymentMethod != nil { 93 | updateQuery["paymentMethod"] = inp.PaymentMethod 94 | } 95 | 96 | _, err := r.db.UpdateOne(ctx, 97 | bson.M{"_id": inp.ID, "schoolId": inp.SchoolID}, bson.M{"$set": updateQuery}) 98 | 99 | return err 100 | } 101 | 102 | func (r *OffersRepo) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error { 103 | _, err := r.db.DeleteOne(ctx, bson.M{"_id": id, "schoolId": schoolId}) 104 | 105 | return err 106 | } 107 | 108 | func (r OffersRepo) GetByIds(ctx context.Context, ids []primitive.ObjectID) ([]domain.Offer, error) { 109 | var offers []domain.Offer 110 | 111 | cur, err := r.db.Find(ctx, bson.M{"_id": bson.M{"$in": ids}}) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | err = cur.All(ctx, &offers) 117 | 118 | return offers, err 119 | } 120 | -------------------------------------------------------------------------------- /internal/repository/orders_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/domain" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "go.mongodb.org/mongo-driver/mongo" 10 | ) 11 | 12 | type OrdersRepo struct { 13 | db *mongo.Collection 14 | } 15 | 16 | func NewOrdersRepo(db *mongo.Database) *OrdersRepo { 17 | return &OrdersRepo{ 18 | db: db.Collection(ordersCollection), 19 | } 20 | } 21 | 22 | func (r *OrdersRepo) Create(ctx context.Context, order domain.Order) error { 23 | _, err := r.db.InsertOne(ctx, order) 24 | 25 | return err 26 | } 27 | 28 | func (r *OrdersRepo) AddTransaction(ctx context.Context, id primitive.ObjectID, transaction domain.Transaction) (domain.Order, error) { 29 | var order domain.Order 30 | 31 | res := r.db.FindOneAndUpdate(ctx, bson.M{"_id": id}, bson.M{ 32 | "$set": bson.M{ 33 | "status": transaction.Status, 34 | }, 35 | "$push": bson.M{ 36 | "transactions": transaction, 37 | }, 38 | }) 39 | if res.Err() != nil { 40 | return order, res.Err() 41 | } 42 | 43 | err := res.Decode(&order) 44 | 45 | return order, err 46 | } 47 | 48 | func (r *OrdersRepo) GetBySchool(ctx context.Context, schoolId primitive.ObjectID, query domain.GetOrdersQuery) ([]domain.Order, int64, error) { 49 | opts := getPaginationOpts(&query.PaginationQuery) 50 | opts.SetSort(bson.M{"createdAt": -1}) 51 | 52 | filter := bson.M{"$and": []bson.M{{"schoolId": schoolId}}} 53 | 54 | if query.Search != "" { 55 | expression := primitive.Regex{Pattern: query.Search} 56 | 57 | filter["$and"] = append(filter["$and"].([]bson.M), bson.M{ 58 | "$or": []bson.M{ 59 | {"student.name": expression}, 60 | {"student.email": expression}, 61 | {"offer.name": expression}, 62 | {"promo.name": expression}, 63 | }, 64 | }) 65 | } 66 | 67 | if query.Status != "" { 68 | filter["$and"] = append(filter["$and"].([]bson.M), bson.M{ 69 | "status": query.Status, 70 | }) 71 | } 72 | 73 | if err := filterDateQueries(query.DateFrom, query.DateTo, "createdAt", filter); err != nil { 74 | return nil, 0, err 75 | } 76 | 77 | cur, err := r.db.Find(ctx, filter, opts) 78 | if err != nil { 79 | return nil, 0, err 80 | } 81 | 82 | var orders []domain.Order 83 | if err := cur.All(ctx, &orders); err != nil { 84 | return nil, 0, err 85 | } 86 | 87 | count, err := r.db.CountDocuments(ctx, filter) 88 | 89 | return orders, count, err 90 | } 91 | 92 | func (r *OrdersRepo) GetById(ctx context.Context, id primitive.ObjectID) (domain.Order, error) { 93 | var order domain.Order 94 | 95 | err := r.db.FindOne(ctx, bson.M{"_id": id}).Decode(&order) 96 | 97 | return order, err 98 | } 99 | 100 | func (r *OrdersRepo) SetStatus(ctx context.Context, id primitive.ObjectID, status string) error { 101 | _, err := r.db.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": bson.M{"status": status}}) 102 | 103 | return err 104 | } 105 | -------------------------------------------------------------------------------- /internal/repository/packages_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/domain" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "go.mongodb.org/mongo-driver/mongo" 10 | ) 11 | 12 | type PackagesRepo struct { 13 | db *mongo.Collection 14 | } 15 | 16 | func NewPackagesRepo(db *mongo.Database) *PackagesRepo { 17 | return &PackagesRepo{db: db.Collection(packagesCollection)} 18 | } 19 | 20 | func (r *PackagesRepo) Create(ctx context.Context, pkg domain.Package) (primitive.ObjectID, error) { 21 | res, err := r.db.InsertOne(ctx, pkg) 22 | if err != nil { 23 | return primitive.ObjectID{}, err 24 | } 25 | 26 | return res.InsertedID.(primitive.ObjectID), nil 27 | } 28 | 29 | func (r *PackagesRepo) GetByCourse(ctx context.Context, courseId primitive.ObjectID) ([]domain.Package, error) { 30 | var packages []domain.Package 31 | 32 | cur, err := r.db.Find(ctx, bson.M{"courseId": courseId}) 33 | if err != nil { 34 | return packages, err 35 | } 36 | 37 | err = cur.All(ctx, &packages) 38 | 39 | return packages, err 40 | } 41 | 42 | func (r *PackagesRepo) GetById(ctx context.Context, id primitive.ObjectID) (domain.Package, error) { 43 | var pkg domain.Package 44 | err := r.db.FindOne(ctx, bson.M{"_id": id}).Decode(&pkg) 45 | 46 | return pkg, err 47 | } 48 | 49 | func (r *PackagesRepo) GetByIds(ctx context.Context, ids []primitive.ObjectID) ([]domain.Package, error) { 50 | var pkgs []domain.Package 51 | 52 | cur, err := r.db.Find(ctx, bson.M{"_id": bson.M{"$in": ids}}) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | err = cur.All(ctx, &pkgs) 58 | 59 | return pkgs, err 60 | } 61 | 62 | func (r *PackagesRepo) Update(ctx context.Context, inp UpdatePackageInput) error { 63 | updateQuery := bson.M{} 64 | 65 | if inp.Name != "" { 66 | updateQuery["name"] = inp.Name 67 | } 68 | 69 | _, err := r.db.UpdateOne(ctx, 70 | bson.M{"_id": inp.ID, "schoolId": inp.SchoolID}, bson.M{"$set": updateQuery}) 71 | 72 | return err 73 | } 74 | 75 | func (r *PackagesRepo) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error { 76 | _, err := r.db.DeleteOne(ctx, bson.M{"_id": id, "schoolId": schoolId}) 77 | 78 | return err 79 | } 80 | -------------------------------------------------------------------------------- /internal/repository/promocodes_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | ) 12 | 13 | type PromocodesRepo struct { 14 | db *mongo.Collection 15 | } 16 | 17 | func NewPromocodeRepo(db *mongo.Database) *PromocodesRepo { 18 | return &PromocodesRepo{db: db.Collection(promocodesCollection)} 19 | } 20 | 21 | func (r *PromocodesRepo) Create(ctx context.Context, promocode domain.PromoCode) (primitive.ObjectID, error) { 22 | res, err := r.db.InsertOne(ctx, promocode) 23 | if err != nil { 24 | return primitive.ObjectID{}, err 25 | } 26 | 27 | return res.InsertedID.(primitive.ObjectID), nil 28 | } 29 | 30 | func (r *PromocodesRepo) Update(ctx context.Context, inp domain.UpdatePromoCodeInput) error { 31 | updateQuery := bson.M{} 32 | 33 | if inp.Code != "" { 34 | updateQuery["code"] = inp.Code 35 | } 36 | 37 | if inp.DiscountPercentage != 0 { 38 | updateQuery["discountPercentage"] = inp.DiscountPercentage 39 | } 40 | 41 | if !inp.ExpiresAt.IsZero() { 42 | updateQuery["expiresAt"] = inp.ExpiresAt 43 | } 44 | 45 | if inp.OfferIDs != nil { 46 | updateQuery["offerIds"] = inp.OfferIDs 47 | } 48 | 49 | _, err := r.db.UpdateOne(ctx, 50 | bson.M{"_id": inp.ID, "schoolId": inp.SchoolID}, bson.M{"$set": updateQuery}) 51 | 52 | return err 53 | } 54 | 55 | func (r *PromocodesRepo) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error { 56 | _, err := r.db.DeleteOne(ctx, bson.M{"_id": id, "schoolId": schoolId}) 57 | 58 | return err 59 | } 60 | 61 | func (r *PromocodesRepo) GetByCode(ctx context.Context, schoolId primitive.ObjectID, code string) (domain.PromoCode, error) { 62 | var promocode domain.PromoCode 63 | if err := r.db.FindOne(ctx, bson.M{"schoolId": schoolId, "code": code}).Decode(&promocode); err != nil { 64 | if errors.Is(err, mongo.ErrNoDocuments) { 65 | return domain.PromoCode{}, domain.ErrPromoNotFound 66 | } 67 | 68 | return domain.PromoCode{}, err 69 | } 70 | 71 | return promocode, nil 72 | } 73 | 74 | func (r *PromocodesRepo) GetById(ctx context.Context, schoolId, id primitive.ObjectID) (domain.PromoCode, error) { 75 | var promocode domain.PromoCode 76 | if err := r.db.FindOne(ctx, bson.M{"_id": id, "schoolId": schoolId}).Decode(&promocode); err != nil { 77 | if errors.Is(err, mongo.ErrNoDocuments) { 78 | return domain.PromoCode{}, domain.ErrPromoNotFound 79 | } 80 | 81 | return domain.PromoCode{}, err 82 | } 83 | 84 | return promocode, nil 85 | } 86 | 87 | func (r *PromocodesRepo) GetBySchool(ctx context.Context, schoolId primitive.ObjectID) ([]domain.PromoCode, error) { 88 | cursor, err := r.db.Find(ctx, bson.M{"schoolId": schoolId}) 89 | if err != nil { 90 | if errors.Is(err, mongo.ErrNoDocuments) { 91 | return nil, domain.ErrPromoNotFound 92 | } 93 | 94 | return nil, err 95 | } 96 | 97 | var promocodes []domain.PromoCode 98 | if err = cursor.All(ctx, &promocodes); err != nil { 99 | return nil, err 100 | } 101 | 102 | return promocodes, nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/repository/schools_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | ) 12 | 13 | type SchoolsRepo struct { 14 | db *mongo.Collection 15 | } 16 | 17 | func NewSchoolsRepo(db *mongo.Database) *SchoolsRepo { 18 | return &SchoolsRepo{ 19 | db: db.Collection(schoolsCollection), 20 | } 21 | } 22 | 23 | func (r *SchoolsRepo) Create(ctx context.Context, name string) (primitive.ObjectID, error) { 24 | res, err := r.db.InsertOne(ctx, domain.School{ 25 | Name: name, 26 | RegisteredAt: time.Now(), 27 | }) 28 | 29 | return res.InsertedID.(primitive.ObjectID), err 30 | } 31 | 32 | func (r *SchoolsRepo) GetByDomain(ctx context.Context, domainName string) (domain.School, error) { 33 | var school domain.School 34 | err := r.db.FindOne(ctx, bson.M{"settings.domains": domainName}).Decode(&school) 35 | 36 | return school, err 37 | } 38 | 39 | func (r *SchoolsRepo) GetById(ctx context.Context, id primitive.ObjectID) (domain.School, error) { 40 | var school domain.School 41 | err := r.db.FindOne(ctx, bson.M{"_id": id}).Decode(&school) 42 | 43 | return school, err 44 | } 45 | 46 | func (r *SchoolsRepo) UpdateSettings(ctx context.Context, id primitive.ObjectID, inp domain.UpdateSchoolSettingsInput) error { 47 | updateQuery := bson.M{} 48 | 49 | if inp.Name != nil { 50 | updateQuery["name"] = *inp.Name 51 | } 52 | 53 | if inp.Color != nil { 54 | updateQuery["settings.color"] = inp.Color 55 | } 56 | 57 | if inp.Domains != nil { 58 | updateQuery["settings.domains"] = inp.Domains 59 | } 60 | 61 | if inp.Email != nil { 62 | updateQuery["settings.email"] = inp.Email 63 | } 64 | 65 | if inp.ContactInfo != nil { 66 | setContactInfoUpdateQuery(&updateQuery, inp) 67 | } 68 | 69 | if inp.Pages != nil { 70 | setPagesUpdateQuery(&updateQuery, inp) 71 | } 72 | 73 | if inp.ShowPaymentImages != nil { 74 | updateQuery["settings.showPaymentImages"] = inp.ShowPaymentImages 75 | } 76 | 77 | if inp.DisableRegistration != nil { 78 | updateQuery["settings.disableRegistration"] = inp.DisableRegistration 79 | } 80 | 81 | if inp.GoogleAnalyticsCode != nil { 82 | updateQuery["settings.googleAnalyticsCode"] = *inp.GoogleAnalyticsCode 83 | } 84 | 85 | if inp.LogoURL != nil { 86 | updateQuery["settings.logo"] = *inp.LogoURL 87 | } 88 | 89 | _, err := r.db.UpdateOne(ctx, 90 | bson.M{"_id": id}, bson.M{"$set": updateQuery}) 91 | 92 | return err 93 | } 94 | 95 | func (r *SchoolsRepo) SetFondyCredentials(ctx context.Context, id primitive.ObjectID, fondy domain.Fondy) error { 96 | _, err := r.db.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": bson.M{"settings.fondy": fondy}}) 97 | 98 | return err 99 | } 100 | 101 | func setContactInfoUpdateQuery(updateQuery *bson.M, inp domain.UpdateSchoolSettingsInput) { 102 | if inp.ContactInfo.Address != nil { 103 | (*updateQuery)["settings.contactInfo.address"] = inp.ContactInfo.Address 104 | } 105 | 106 | if inp.ContactInfo.BusinessName != nil { 107 | (*updateQuery)["settings.contactInfo.businessName"] = inp.ContactInfo.BusinessName 108 | } 109 | 110 | if inp.ContactInfo.Email != nil { 111 | (*updateQuery)["settings.contactInfo.email"] = inp.ContactInfo.Email 112 | } 113 | 114 | if inp.ContactInfo.Phone != nil { 115 | (*updateQuery)["settings.contactInfo.phone"] = inp.ContactInfo.Phone 116 | } 117 | 118 | if inp.ContactInfo.RegistrationNumber != nil { 119 | (*updateQuery)["settings.contactInfo.registrationNumber"] = inp.ContactInfo.RegistrationNumber 120 | } 121 | } 122 | 123 | func setPagesUpdateQuery(updateQuery *bson.M, inp domain.UpdateSchoolSettingsInput) { 124 | if inp.Pages.Confidential != nil { 125 | (*updateQuery)["settings.pages.confidential"] = inp.Pages.Confidential 126 | } 127 | 128 | if inp.Pages.NewsletterConsent != nil { 129 | (*updateQuery)["settings.pages.newsletterConsent"] = inp.Pages.NewsletterConsent 130 | } 131 | 132 | if inp.Pages.ServiceAgreement != nil { 133 | (*updateQuery)["settings.pages.serviceAgreement"] = inp.Pages.ServiceAgreement 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /internal/repository/student_lessons_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "go.mongodb.org/mongo-driver/bson" 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | "go.mongodb.org/mongo-driver/mongo" 9 | "go.mongodb.org/mongo-driver/mongo/options" 10 | ) 11 | 12 | type StudentLessonsRepo struct { 13 | db *mongo.Collection 14 | } 15 | 16 | func NewStudentLessonsRepo(db *mongo.Database) *StudentLessonsRepo { 17 | return &StudentLessonsRepo{db: db.Collection(studentLessonsCollection)} 18 | } 19 | 20 | func (r *StudentLessonsRepo) AddFinished(ctx context.Context, studentID, lessonID primitive.ObjectID) error { 21 | filter := bson.M{"studentId": studentID} 22 | update := bson.M{"$addToSet": bson.M{"finished": lessonID}} 23 | 24 | _, err := r.db.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true)) 25 | 26 | return err 27 | } 28 | 29 | func (r *StudentLessonsRepo) SetLastOpened(ctx context.Context, studentID, lessonID primitive.ObjectID) error { 30 | filter := bson.M{"studentId": studentID} 31 | update := bson.M{"$set": bson.M{"lastOpened": lessonID}} 32 | 33 | _, err := r.db.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true)) 34 | 35 | return err 36 | } 37 | -------------------------------------------------------------------------------- /internal/repository/survey_results_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/domain" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "go.mongodb.org/mongo-driver/mongo" 10 | ) 11 | 12 | type SurveyResultsRepo struct { 13 | db *mongo.Collection 14 | } 15 | 16 | func NewSurveyResultsRepo(db *mongo.Database) *SurveyResultsRepo { 17 | return &SurveyResultsRepo{ 18 | db: db.Collection(surveyResultsCollection), 19 | } 20 | } 21 | 22 | func (r *SurveyResultsRepo) Save(ctx context.Context, results domain.SurveyResult) error { 23 | _, err := r.db.InsertOne(ctx, results) 24 | 25 | return err 26 | } 27 | 28 | func (r *SurveyResultsRepo) GetAllByModule(ctx context.Context, moduleID primitive.ObjectID, pagination *domain.PaginationQuery) ([]domain.SurveyResult, int64, error) { 29 | opts := getPaginationOpts(pagination) 30 | filter := bson.M{"moduleId": moduleID} 31 | 32 | cur, err := r.db.Find(ctx, filter, opts) 33 | if err != nil { 34 | return nil, 0, err 35 | } 36 | 37 | var results []domain.SurveyResult 38 | if err := cur.All(ctx, &results); err != nil { 39 | return nil, 0, err 40 | } 41 | 42 | count, err := r.db.CountDocuments(ctx, filter) 43 | 44 | return results, count, err 45 | } 46 | 47 | func (r *SurveyResultsRepo) GetByStudent(ctx context.Context, moduleID, studentID primitive.ObjectID) (domain.SurveyResult, error) { 48 | var res domain.SurveyResult 49 | err := r.db.FindOne(ctx, bson.M{"student.id": studentID, "moduleId": moduleID}).Decode(&res) 50 | 51 | return res, err 52 | } 53 | -------------------------------------------------------------------------------- /internal/repository/users_mongo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/zhashkevych/creatly-backend/internal/domain" 9 | "github.com/zhashkevych/creatly-backend/pkg/database/mongodb" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | "go.mongodb.org/mongo-driver/mongo" 13 | ) 14 | 15 | type UsersRepo struct { 16 | db *mongo.Collection 17 | } 18 | 19 | func NewUsersRepo(db *mongo.Database) *UsersRepo { 20 | return &UsersRepo{ 21 | db: db.Collection(usersCollection), 22 | } 23 | } 24 | 25 | func (r *UsersRepo) Create(ctx context.Context, user domain.User) error { 26 | _, err := r.db.InsertOne(ctx, user) 27 | if mongodb.IsDuplicate(err) { 28 | return domain.ErrUserAlreadyExists 29 | } 30 | 31 | return err 32 | } 33 | 34 | func (r *UsersRepo) GetByCredentials(ctx context.Context, email, password string) (domain.User, error) { 35 | var user domain.User 36 | if err := r.db.FindOne(ctx, bson.M{"email": email, "password": password}).Decode(&user); err != nil { 37 | if errors.Is(err, mongo.ErrNoDocuments) { 38 | return domain.User{}, domain.ErrUserNotFound 39 | } 40 | 41 | return domain.User{}, err 42 | } 43 | 44 | return user, nil 45 | } 46 | 47 | func (r *UsersRepo) GetByRefreshToken(ctx context.Context, refreshToken string) (domain.User, error) { 48 | var user domain.User 49 | if err := r.db.FindOne(ctx, bson.M{ 50 | "session.refreshToken": refreshToken, 51 | "session.expiresAt": bson.M{"$gt": time.Now()}, 52 | }).Decode(&user); err != nil { 53 | if errors.Is(err, mongo.ErrNoDocuments) { 54 | return domain.User{}, domain.ErrUserNotFound 55 | } 56 | 57 | return domain.User{}, err 58 | } 59 | 60 | return user, nil 61 | } 62 | 63 | func (r *UsersRepo) Verify(ctx context.Context, userID primitive.ObjectID, code string) error { 64 | res, err := r.db.UpdateOne(ctx, 65 | bson.M{"verification.code": code, "_id": userID}, 66 | bson.M{"$set": bson.M{"verification.verified": true, "verification.code": ""}}) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | if res.ModifiedCount == 0 { 72 | return domain.ErrVerificationCodeInvalid 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (r *UsersRepo) SetSession(ctx context.Context, userID primitive.ObjectID, session domain.Session) error { 79 | _, err := r.db.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{"session": session, "lastVisitAt": time.Now()}}) 80 | 81 | return err 82 | } 83 | 84 | func (r *UsersRepo) AttachSchool(ctx context.Context, userID, schoolId primitive.ObjectID) error { 85 | _, err := r.db.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$push": bson.M{"schools": schoolId}}) 86 | 87 | return err 88 | } 89 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/config" 8 | ) 9 | 10 | type Server struct { 11 | httpServer *http.Server 12 | } 13 | 14 | func NewServer(cfg *config.Config, handler http.Handler) *Server { 15 | return &Server{ 16 | httpServer: &http.Server{ 17 | Addr: ":" + cfg.HTTP.Port, 18 | Handler: handler, 19 | ReadTimeout: cfg.HTTP.ReadTimeout, 20 | WriteTimeout: cfg.HTTP.WriteTimeout, 21 | MaxHeaderBytes: cfg.HTTP.MaxHeaderMegabytes << 20, 22 | }, 23 | } 24 | } 25 | 26 | func (s *Server) Run() error { 27 | return s.httpServer.ListenAndServe() 28 | } 29 | 30 | func (s *Server) Stop(ctx context.Context) error { 31 | return s.httpServer.Shutdown(ctx) 32 | } 33 | -------------------------------------------------------------------------------- /internal/service/admins.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/zhashkevych/creatly-backend/internal/domain" 9 | "github.com/zhashkevych/creatly-backend/internal/repository" 10 | "github.com/zhashkevych/creatly-backend/pkg/auth" 11 | "github.com/zhashkevych/creatly-backend/pkg/hash" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | ) 14 | 15 | type AdminsService struct { 16 | hasher hash.PasswordHasher 17 | tokenManager auth.TokenManager 18 | 19 | repo repository.Admins 20 | schoolRepo repository.Schools 21 | studentRepo repository.Students 22 | 23 | accessTokenTTL time.Duration 24 | refreshTokenTTL time.Duration 25 | } 26 | 27 | func NewAdminsService(hasher hash.PasswordHasher, tokenManager auth.TokenManager, 28 | repo repository.Admins, schoolRepo repository.Schools, studentRepo repository.Students, 29 | accessTokenTTL time.Duration, refreshTokenTTL time.Duration) *AdminsService { 30 | return &AdminsService{ 31 | hasher: hasher, 32 | tokenManager: tokenManager, 33 | repo: repo, 34 | schoolRepo: schoolRepo, 35 | studentRepo: studentRepo, 36 | accessTokenTTL: accessTokenTTL, 37 | refreshTokenTTL: refreshTokenTTL, 38 | } 39 | } 40 | 41 | func (s *AdminsService) SignIn(ctx context.Context, input SchoolSignInInput) (Tokens, error) { 42 | // student, err := s.repo.GetByCredentials(ctx, input.SchoolID, input.Email, s.hasher.Hash(input.Password)) 43 | student, err := s.repo.GetByCredentials(ctx, input.SchoolID, input.Email, input.Password) // TODO implement password hashing 44 | if err != nil { 45 | return Tokens{}, err 46 | } 47 | 48 | return s.createSession(ctx, student.ID) 49 | } 50 | 51 | func (s *AdminsService) RefreshTokens(ctx context.Context, schoolId primitive.ObjectID, refreshToken string) (Tokens, error) { 52 | student, err := s.repo.GetByRefreshToken(ctx, schoolId, refreshToken) 53 | if err != nil { 54 | return Tokens{}, err 55 | } 56 | 57 | return s.createSession(ctx, student.ID) 58 | } 59 | 60 | func (s *AdminsService) GetCourses(ctx context.Context, schoolId primitive.ObjectID) ([]domain.Course, error) { 61 | school, err := s.schoolRepo.GetById(ctx, schoolId) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | return school.Courses, nil 67 | } 68 | 69 | func (s *AdminsService) GetCourseById(ctx context.Context, schoolID, courseID primitive.ObjectID) (domain.Course, error) { 70 | school, err := s.schoolRepo.GetById(ctx, schoolID) 71 | if err != nil { 72 | return domain.Course{}, err 73 | } 74 | 75 | var searchedCourse domain.Course 76 | 77 | for _, course := range school.Courses { 78 | if course.ID == courseID { 79 | searchedCourse = course 80 | } 81 | } 82 | 83 | if searchedCourse.ID.IsZero() { 84 | return domain.Course{}, errors.New("not found") 85 | } 86 | 87 | return searchedCourse, nil 88 | } 89 | 90 | func (s *AdminsService) CreateStudent(ctx context.Context, inp domain.CreateStudentInput) (domain.Student, error) { 91 | passwordHash, err := s.hasher.Hash(inp.Password) 92 | if err != nil { 93 | return domain.Student{}, err 94 | } 95 | 96 | student := domain.Student{ 97 | Name: inp.Name, 98 | Email: inp.Email, 99 | Password: passwordHash, 100 | RegisteredAt: time.Now(), 101 | SchoolID: inp.SchoolID, 102 | Verification: domain.Verification{Verified: true}, 103 | } 104 | err = s.studentRepo.Create(ctx, &student) 105 | 106 | return student, err 107 | } 108 | 109 | func (s *AdminsService) UpdateStudent(ctx context.Context, inp domain.UpdateStudentInput) error { 110 | return s.studentRepo.Update(ctx, inp) 111 | } 112 | 113 | func (s *AdminsService) DeleteStudent(ctx context.Context, schoolId, studentId primitive.ObjectID) error { 114 | return s.studentRepo.Delete(ctx, schoolId, studentId) 115 | } 116 | 117 | func (s *AdminsService) createSession(ctx context.Context, adminID primitive.ObjectID) (Tokens, error) { 118 | var ( 119 | res Tokens 120 | err error 121 | ) 122 | 123 | res.AccessToken, err = s.tokenManager.NewJWT(adminID.Hex(), s.accessTokenTTL) 124 | if err != nil { 125 | return res, err 126 | } 127 | 128 | res.RefreshToken, err = s.tokenManager.NewRefreshToken() 129 | if err != nil { 130 | return res, err 131 | } 132 | 133 | session := domain.Session{ 134 | RefreshToken: res.RefreshToken, 135 | ExpiresAt: time.Now().Add(s.refreshTokenTTL), 136 | } 137 | 138 | err = s.repo.SetSession(ctx, adminID, session) 139 | 140 | return res, err 141 | } 142 | -------------------------------------------------------------------------------- /internal/service/admins_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/golang/mock/gomock" 10 | "github.com/stretchr/testify/require" 11 | "github.com/zhashkevych/creatly-backend/internal/domain" 12 | mock_repository "github.com/zhashkevych/creatly-backend/internal/repository/mocks" 13 | "github.com/zhashkevych/creatly-backend/internal/service" 14 | "github.com/zhashkevych/creatly-backend/pkg/auth" 15 | "github.com/zhashkevych/creatly-backend/pkg/hash" 16 | "go.mongodb.org/mongo-driver/bson/primitive" 17 | ) 18 | 19 | var errInternalServErr = errors.New("test: internal server error") 20 | 21 | func mockAdminService(t *testing.T) (*service.AdminsService, *mock_repository.MockAdmins, *mock_repository.MockSchools) { 22 | t.Helper() 23 | 24 | mockCtl := gomock.NewController(t) 25 | defer mockCtl.Finish() 26 | 27 | adminRepo := mock_repository.NewMockAdmins(mockCtl) 28 | schoolsRepo := mock_repository.NewMockSchools(mockCtl) 29 | studentsRepo := mock_repository.NewMockStudents(mockCtl) 30 | 31 | adminService := service.NewAdminsService( 32 | &hash.SHA1Hasher{}, 33 | &auth.Manager{}, 34 | adminRepo, 35 | schoolsRepo, 36 | studentsRepo, 37 | 1*time.Minute, 38 | 1*time.Minute, 39 | ) 40 | 41 | return adminService, adminRepo, schoolsRepo 42 | } 43 | 44 | func TestNewAdminsService_SignInErr(t *testing.T) { 45 | adminService, adminRepo, _ := mockAdminService(t) 46 | 47 | ctx := context.Background() 48 | 49 | adminRepo.EXPECT().GetByCredentials(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Return(domain.Admin{}, errInternalServErr) 50 | adminRepo.EXPECT().SetSession(ctx, gomock.Any(), gomock.Any()) 51 | 52 | res, err := adminService.SignIn(ctx, service.SchoolSignInInput{}) 53 | 54 | require.True(t, errors.Is(err, errInternalServErr)) 55 | require.Equal(t, service.Tokens{}, res) 56 | } 57 | 58 | func TestNewAdminsService_SignIn(t *testing.T) { 59 | adminService, adminRepo, _ := mockAdminService(t) 60 | 61 | ctx := context.Background() 62 | 63 | adminRepo.EXPECT().GetByCredentials(ctx, gomock.Any(), gomock.Any(), gomock.Any()) 64 | adminRepo.EXPECT().SetSession(ctx, gomock.Any(), gomock.Any()) 65 | 66 | res, err := adminService.SignIn(ctx, service.SchoolSignInInput{}) 67 | 68 | require.NoError(t, err) 69 | require.IsType(t, service.Tokens{}, res) 70 | } 71 | 72 | func TestNewAdminsService_RefreshTokensErr(t *testing.T) { 73 | adminService, adminRepo, _ := mockAdminService(t) 74 | 75 | ctx := context.Background() 76 | 77 | adminRepo.EXPECT().GetByRefreshToken(ctx, gomock.Any(), gomock.Any()).Return(domain.Admin{}, errInternalServErr) 78 | 79 | res, err := adminService.RefreshTokens(ctx, primitive.ObjectID{}, "") 80 | 81 | require.True(t, errors.Is(err, errInternalServErr)) 82 | require.Equal(t, service.Tokens{}, res) 83 | } 84 | 85 | func TestNewAdminsService_RefreshTokens(t *testing.T) { 86 | adminService, adminRepo, _ := mockAdminService(t) 87 | 88 | ctx := context.Background() 89 | 90 | adminRepo.EXPECT().GetByRefreshToken(ctx, gomock.Any(), gomock.Any()) 91 | adminRepo.EXPECT().SetSession(ctx, gomock.Any(), gomock.Any()) 92 | 93 | res, err := adminService.RefreshTokens(ctx, primitive.ObjectID{}, "") 94 | 95 | require.NoError(t, err) 96 | require.IsType(t, service.Tokens{}, res) 97 | } 98 | 99 | func TestNewAdminsService_GetCoursesErr(t *testing.T) { 100 | adminService, _, schoolsRepo := mockAdminService(t) 101 | 102 | ctx := context.Background() 103 | 104 | schoolsRepo.EXPECT().GetById(ctx, gomock.Any()).Return(domain.School{}, errInternalServErr) 105 | 106 | res, err := adminService.GetCourses(ctx, primitive.ObjectID{}) 107 | 108 | require.True(t, errors.Is(err, errInternalServErr)) 109 | require.Equal(t, []domain.Course(nil), res) 110 | } 111 | 112 | func TestNewAdminsService_GetCourses(t *testing.T) { 113 | adminService, _, schoolsRepo := mockAdminService(t) 114 | 115 | ctx := context.Background() 116 | 117 | schoolsRepo.EXPECT().GetById(ctx, gomock.Any()) 118 | 119 | res, err := adminService.GetCourses(ctx, primitive.ObjectID{}) 120 | 121 | require.NoError(t, err) 122 | require.IsType(t, []domain.Course{}, res) 123 | } 124 | 125 | func TestNewAdminsService_GetCourseByIdErr(t *testing.T) { 126 | adminService, _, schoolsRepo := mockAdminService(t) 127 | 128 | ctx := context.Background() 129 | 130 | schoolsRepo.EXPECT().GetById(ctx, gomock.Any()).Return(domain.School{}, errInternalServErr) 131 | 132 | res, err := adminService.GetCourseById(ctx, primitive.ObjectID{}, primitive.ObjectID{}) 133 | 134 | require.True(t, errors.Is(err, errInternalServErr)) 135 | require.Equal(t, domain.Course{}, res) 136 | } 137 | 138 | func TestNewAdminsService_GetCourseByIdNotFoundErr(t *testing.T) { 139 | adminService, _, schoolsRepo := mockAdminService(t) 140 | 141 | ctx := context.Background() 142 | 143 | schoolsRepo.EXPECT().GetById(ctx, gomock.Any()) 144 | 145 | _, err := adminService.GetCourseById(ctx, primitive.ObjectID{}, primitive.ObjectID{}) 146 | 147 | require.Error(t, err) 148 | } 149 | 150 | func TestNewAdminsService_GetCourseById(t *testing.T) { 151 | adminService, _, schoolsRepo := mockAdminService(t) 152 | 153 | ctx := context.Background() 154 | s := domain.School{ 155 | ID: primitive.NewObjectID(), 156 | Courses: []domain.Course{ 157 | { 158 | ID: primitive.NewObjectID(), 159 | }, 160 | }, 161 | } 162 | 163 | schoolsRepo.EXPECT().GetById(ctx, gomock.Any()).Return(s, nil) 164 | 165 | _, err := adminService.GetCourseById(ctx, s.ID, s.Courses[0].ID) 166 | 167 | require.NoError(t, err) 168 | } 169 | -------------------------------------------------------------------------------- /internal/service/courses.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "github.com/zhashkevych/creatly-backend/internal/repository" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | type CoursesService struct { 13 | repo repository.Courses 14 | modulesService Modules 15 | } 16 | 17 | func NewCoursesService(repo repository.Courses, modulesService Modules) *CoursesService { 18 | return &CoursesService{repo: repo, modulesService: modulesService} 19 | } 20 | 21 | func (s *CoursesService) Create(ctx context.Context, schoolId primitive.ObjectID, name string) (primitive.ObjectID, error) { 22 | return s.repo.Create(ctx, schoolId, domain.Course{ 23 | Name: name, 24 | CreatedAt: time.Now(), 25 | UpdatedAt: time.Now(), 26 | }) 27 | } 28 | 29 | func (s *CoursesService) Update(ctx context.Context, inp UpdateCourseInput) error { 30 | updateInput := repository.UpdateCourseInput{ 31 | Name: inp.Name, 32 | ImageURL: inp.ImageURL, 33 | Description: inp.Description, 34 | Color: inp.Color, 35 | Published: inp.Published, 36 | } 37 | 38 | var err error 39 | 40 | updateInput.ID, err = primitive.ObjectIDFromHex(inp.CourseID) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | updateInput.SchoolID, err = primitive.ObjectIDFromHex(inp.SchoolID) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | return s.repo.Update(ctx, updateInput) 51 | } 52 | 53 | func (s *CoursesService) Delete(ctx context.Context, schoolId, courseId primitive.ObjectID) error { 54 | if err := s.repo.Delete(ctx, schoolId, courseId); err != nil { 55 | return err 56 | } 57 | 58 | return s.modulesService.DeleteByCourse(ctx, schoolId, courseId) 59 | } 60 | -------------------------------------------------------------------------------- /internal/service/emails.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/config" 8 | "github.com/zhashkevych/creatly-backend/internal/domain" 9 | "github.com/zhashkevych/creatly-backend/pkg/cache" 10 | emailProvider "github.com/zhashkevych/creatly-backend/pkg/email" 11 | "github.com/zhashkevych/creatly-backend/pkg/email/sendpulse" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | ) 14 | 15 | const ( 16 | verificationLinkTmpl = "https://%s/verification?code=%s" // https:///verification?code= 17 | ) 18 | 19 | type EmailService struct { 20 | sender emailProvider.Sender 21 | config config.EmailConfig 22 | schools SchoolsService 23 | 24 | cache cache.Cache 25 | 26 | sendpulseClients map[primitive.ObjectID]*sendpulse.Client 27 | } 28 | 29 | // Structures used for templates. 30 | type verificationEmailInput struct { 31 | VerificationLink string 32 | } 33 | 34 | type purchaseSuccessfulEmailInput struct { 35 | Name string 36 | CourseName string 37 | } 38 | 39 | func NewEmailsService(sender emailProvider.Sender, config config.EmailConfig, schools SchoolsService, cache cache.Cache) *EmailService { 40 | return &EmailService{ 41 | sender: sender, 42 | config: config, 43 | schools: schools, 44 | cache: cache, 45 | sendpulseClients: make(map[primitive.ObjectID]*sendpulse.Client), 46 | } 47 | } 48 | 49 | func (s *EmailService) SendStudentVerificationEmail(input VerificationEmailInput) error { 50 | subject := fmt.Sprintf(s.config.Subjects.Verification, input.Name) 51 | 52 | templateInput := verificationEmailInput{s.createVerificationLink(input.Domain, input.VerificationCode)} 53 | sendInput := emailProvider.SendEmailInput{Subject: subject, To: input.Email} 54 | 55 | if err := sendInput.GenerateBodyFromHTML(s.config.Templates.Verification, templateInput); err != nil { 56 | return err 57 | } 58 | 59 | return s.sender.Send(sendInput) 60 | } 61 | 62 | func (s *EmailService) SendStudentPurchaseSuccessfulEmail(input StudentPurchaseSuccessfulEmailInput) error { 63 | templateInput := purchaseSuccessfulEmailInput{Name: input.Name, CourseName: input.CourseName} 64 | sendInput := emailProvider.SendEmailInput{Subject: s.config.Subjects.PurchaseSuccessful, To: input.Email} 65 | 66 | if err := sendInput.GenerateBodyFromHTML(s.config.Templates.PurchaseSuccessful, templateInput); err != nil { 67 | return err 68 | } 69 | 70 | return s.sender.Send(sendInput) 71 | } 72 | 73 | func (s *EmailService) SendUserVerificationEmail(input VerificationEmailInput) error { 74 | // todo implement 75 | return nil 76 | } 77 | 78 | func (s *EmailService) createVerificationLink(domain, code string) string { 79 | return fmt.Sprintf(verificationLinkTmpl, domain, code) 80 | } 81 | 82 | func (s *EmailService) AddStudentToList(ctx context.Context, email, name string, schoolID primitive.ObjectID) error { 83 | // TODO refactor 84 | school, err := s.schools.GetById(ctx, schoolID) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | if !school.Settings.SendPulse.Connected { 90 | return domain.ErrSendPulseIsNotConnected 91 | } 92 | 93 | client, ex := s.sendpulseClients[schoolID] 94 | if !ex { 95 | client = sendpulse.NewClient(school.Settings.SendPulse.ID, school.Settings.SendPulse.Secret, s.cache) 96 | s.sendpulseClients[schoolID] = client 97 | } 98 | 99 | return client.AddEmailToList(emailProvider.AddEmailInput{ 100 | Email: email, 101 | ListID: school.Settings.SendPulse.ListID, 102 | Variables: map[string]string{ 103 | "Name": name, 104 | "source": "registration", 105 | }, 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /internal/service/files.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/zhashkevych/creatly-backend/internal/domain" 12 | "github.com/zhashkevych/creatly-backend/internal/repository" 13 | "github.com/zhashkevych/creatly-backend/pkg/logger" 14 | "go.mongodb.org/mongo-driver/bson/primitive" 15 | "go.mongodb.org/mongo-driver/mongo" 16 | 17 | "github.com/google/uuid" 18 | "github.com/zhashkevych/creatly-backend/pkg/storage" 19 | ) 20 | 21 | const ( 22 | _workersCount = 2 23 | _workerInterval = time.Second * 10 24 | ) 25 | 26 | var folders = map[domain.FileType]string{ 27 | domain.Image: "images", 28 | domain.Video: "videos", 29 | domain.Other: "other", 30 | } 31 | 32 | type FilesService struct { 33 | repo repository.Files 34 | storage storage.Provider 35 | env string 36 | } 37 | 38 | func NewFilesService(repo repository.Files, storage storage.Provider, env string) *FilesService { 39 | return &FilesService{repo: repo, storage: storage, env: env} 40 | } 41 | 42 | func (s *FilesService) Save(ctx context.Context, file domain.File) (primitive.ObjectID, error) { 43 | return s.repo.Create(ctx, file) 44 | } 45 | 46 | func (s *FilesService) UpdateStatus(ctx context.Context, fileName string, status domain.FileStatus) error { 47 | return s.repo.UpdateStatus(ctx, fileName, status) 48 | } 49 | 50 | func (s *FilesService) GetByID(ctx context.Context, id, schoolId primitive.ObjectID) (domain.File, error) { 51 | return s.repo.GetByID(ctx, id, schoolId) 52 | } 53 | 54 | func (s *FilesService) UploadAndSaveFile(ctx context.Context, file domain.File) (string, error) { 55 | defer removeFile(file.Name) 56 | 57 | file.UploadStartedAt = time.Now() 58 | 59 | if _, err := s.Save(ctx, file); err != nil { 60 | return "", err 61 | } 62 | 63 | return s.upload(ctx, file) 64 | } 65 | 66 | func (s *FilesService) InitStorageUploaderWorkers(ctx context.Context) { 67 | for i := 0; i < _workersCount; i++ { 68 | go s.processUploadToStorage(ctx) 69 | } 70 | } 71 | 72 | func (s *FilesService) processUploadToStorage(ctx context.Context) { 73 | for { 74 | if err := s.uploadToStorage(ctx); err != nil { 75 | logger.Error("uploadToStorage(): ", err) 76 | } 77 | 78 | time.Sleep(_workerInterval) 79 | } 80 | } 81 | 82 | func (s *FilesService) uploadToStorage(ctx context.Context) error { 83 | file, err := s.repo.GetForUploading(ctx) 84 | if err != nil { 85 | if errors.Is(err, mongo.ErrNoDocuments) { 86 | return nil 87 | } 88 | 89 | return err 90 | } 91 | 92 | defer removeFile(file.Name) 93 | 94 | logger.Infof("processing file %s", file.Name) 95 | 96 | url, err := s.upload(ctx, file) 97 | if err != nil { 98 | if err := s.repo.UpdateStatus(ctx, file.Name, domain.StorageUploadError); err != nil { 99 | return err 100 | } 101 | 102 | return err 103 | } 104 | 105 | logger.Infof("file %s processed successfully", file.Name) 106 | 107 | if err := s.repo.UpdateStatusAndSetURL(ctx, file.ID, url); err != nil { 108 | return err 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func (s *FilesService) upload(ctx context.Context, file domain.File) (string, error) { 115 | f, err := os.Open(file.Name) 116 | if err != nil { 117 | return "", err 118 | } 119 | 120 | info, _ := f.Stat() 121 | logger.Infof("file info: %+v", info) 122 | 123 | defer f.Close() 124 | 125 | return s.storage.Upload(ctx, storage.UploadInput{ 126 | File: f, 127 | Size: file.Size, 128 | ContentType: file.ContentType, 129 | Name: s.generateFilename(file), 130 | }) 131 | } 132 | 133 | func (s *FilesService) generateFilename(file domain.File) string { 134 | filename := fmt.Sprintf("%s.%s", uuid.New().String(), getFileExtension(file.Name)) 135 | folder := folders[file.Type] 136 | 137 | fileNameParts := strings.Split(file.Name, "-") // first part is schoolId 138 | 139 | return fmt.Sprintf("%s/%s/%s/%s", s.env, fileNameParts[0], folder, filename) 140 | } 141 | 142 | func getFileExtension(filename string) string { 143 | parts := strings.Split(filename, ".") 144 | 145 | return parts[len(parts)-1] 146 | } 147 | 148 | func removeFile(filename string) { 149 | if err := os.Remove(filename); err != nil { 150 | logger.Error("removeFile(): ", err) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /internal/service/lessons.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "github.com/zhashkevych/creatly-backend/internal/repository" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | ) 12 | 13 | type LessonsService struct { 14 | repo repository.Modules 15 | contentRepo repository.LessonContent 16 | } 17 | 18 | func NewLessonsService(repo repository.Modules, contentRepo repository.LessonContent) *LessonsService { 19 | return &LessonsService{repo: repo, contentRepo: contentRepo} 20 | } 21 | 22 | func (s *LessonsService) Create(ctx context.Context, inp AddLessonInput) (primitive.ObjectID, error) { 23 | schoolID, err := primitive.ObjectIDFromHex(inp.SchoolID) 24 | if err != nil { 25 | return primitive.ObjectID{}, err 26 | } 27 | 28 | lesson := domain.Lesson{ 29 | ID: primitive.NewObjectID(), 30 | SchoolID: schoolID, 31 | Name: inp.Name, 32 | Position: inp.Position, 33 | } 34 | 35 | id, err := primitive.ObjectIDFromHex(inp.ModuleID) 36 | if err != nil { 37 | return primitive.ObjectID{}, err 38 | } 39 | 40 | if err := s.repo.AddLesson(ctx, schoolID, id, lesson); err != nil { 41 | return primitive.ObjectID{}, err 42 | } 43 | 44 | return lesson.ID, nil 45 | } 46 | 47 | func (s *LessonsService) GetById(ctx context.Context, lessonId primitive.ObjectID) (domain.Lesson, error) { 48 | module, err := s.repo.GetByLesson(ctx, lessonId) 49 | if err != nil { 50 | return domain.Lesson{}, err 51 | } 52 | 53 | var lesson domain.Lesson 54 | 55 | for _, l := range module.Lessons { 56 | if l.ID == lessonId { 57 | lesson = l 58 | } 59 | } 60 | 61 | content, err := s.contentRepo.GetByLesson(ctx, lessonId) 62 | if err != nil { 63 | if errors.Is(err, mongo.ErrNoDocuments) { 64 | return lesson, nil 65 | } 66 | 67 | return lesson, err 68 | } 69 | 70 | lesson.Content = content.Content 71 | 72 | return lesson, nil 73 | } 74 | 75 | func (s *LessonsService) Update(ctx context.Context, inp UpdateLessonInput) error { 76 | id, err := primitive.ObjectIDFromHex(inp.LessonID) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | schoolID, err := primitive.ObjectIDFromHex(inp.SchoolID) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | if inp.Name != "" || inp.Position != nil || inp.Published != nil { 87 | if err := s.repo.UpdateLesson(ctx, repository.UpdateLessonInput{ 88 | ID: id, 89 | Name: inp.Name, 90 | Position: inp.Position, 91 | Published: inp.Published, 92 | SchoolID: schoolID, 93 | }); err != nil { 94 | return err 95 | } 96 | } 97 | 98 | if inp.Content != "" { 99 | if err := s.contentRepo.Update(ctx, schoolID, id, inp.Content); err != nil { 100 | return err 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func (s *LessonsService) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error { 108 | return s.repo.DeleteLesson(ctx, schoolId, id) 109 | } 110 | 111 | func (s *LessonsService) DeleteContent(ctx context.Context, schoolId primitive.ObjectID, lessonIds []primitive.ObjectID) error { 112 | return s.contentRepo.DeleteContent(ctx, schoolId, lessonIds) 113 | } 114 | -------------------------------------------------------------------------------- /internal/service/modules.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "github.com/zhashkevych/creatly-backend/internal/repository" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | type ModulesService struct { 13 | repo repository.Modules 14 | contentRepo repository.LessonContent 15 | } 16 | 17 | func NewModulesService(repo repository.Modules, contentRepo repository.LessonContent) *ModulesService { 18 | return &ModulesService{repo: repo, contentRepo: contentRepo} 19 | } 20 | 21 | func (s *ModulesService) GetPublishedByCourseId(ctx context.Context, courseId primitive.ObjectID) ([]domain.Module, error) { 22 | modules, err := s.repo.GetPublishedByCourseId(ctx, courseId) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | for i := range modules { 28 | sortLessons(modules[i].Lessons) 29 | } 30 | 31 | return modules, nil 32 | } 33 | 34 | func (s *ModulesService) GetByCourseId(ctx context.Context, courseId primitive.ObjectID) ([]domain.Module, error) { 35 | modules, err := s.repo.GetByCourseId(ctx, courseId) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | for i := range modules { 41 | sortLessons(modules[i].Lessons) 42 | } 43 | 44 | return modules, nil 45 | } 46 | 47 | func (s *ModulesService) GetById(ctx context.Context, moduleId primitive.ObjectID) (domain.Module, error) { 48 | module, err := s.repo.GetPublishedById(ctx, moduleId) 49 | if err != nil { 50 | return module, err 51 | } 52 | 53 | sortLessons(module.Lessons) 54 | 55 | return module, nil 56 | } 57 | 58 | func (s *ModulesService) GetWithContent(ctx context.Context, moduleID primitive.ObjectID) (domain.Module, error) { 59 | module, err := s.repo.GetById(ctx, moduleID) 60 | if err != nil { 61 | return module, err 62 | } 63 | 64 | lessonIds := make([]primitive.ObjectID, len(module.Lessons)) 65 | publishedLessons := make([]domain.Lesson, 0) 66 | 67 | for _, lesson := range module.Lessons { 68 | if lesson.Published { 69 | publishedLessons = append(publishedLessons, lesson) 70 | lessonIds = append(lessonIds, lesson.ID) 71 | } 72 | } 73 | 74 | module.Lessons = publishedLessons // remove unpublished lessons from final result 75 | 76 | content, err := s.contentRepo.GetByLessons(ctx, lessonIds) 77 | if err != nil { 78 | return module, err 79 | } 80 | 81 | for i := range module.Lessons { 82 | for _, lessonContent := range content { 83 | if module.Lessons[i].ID == lessonContent.LessonID { 84 | module.Lessons[i].Content = lessonContent.Content 85 | } 86 | } 87 | } 88 | 89 | sortLessons(module.Lessons) 90 | 91 | return module, nil 92 | } 93 | 94 | func (s *ModulesService) GetByPackages(ctx context.Context, packageIds []primitive.ObjectID) ([]domain.Module, error) { 95 | modules, err := s.repo.GetByPackages(ctx, packageIds) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | for i := range modules { 101 | sortLessons(modules[i].Lessons) 102 | } 103 | 104 | return modules, nil 105 | } 106 | 107 | func (s *ModulesService) GetByLesson(ctx context.Context, lessonID primitive.ObjectID) (domain.Module, error) { 108 | return s.repo.GetByLesson(ctx, lessonID) 109 | } 110 | 111 | func (s *ModulesService) Create(ctx context.Context, inp CreateModuleInput) (primitive.ObjectID, error) { 112 | id, err := primitive.ObjectIDFromHex(inp.CourseID) 113 | if err != nil { 114 | return id, err 115 | } 116 | 117 | schoolID, err := primitive.ObjectIDFromHex(inp.SchoolID) 118 | if err != nil { 119 | return id, err 120 | } 121 | 122 | module := domain.Module{ 123 | Name: inp.Name, 124 | Position: inp.Position, 125 | CourseID: id, 126 | SchoolID: schoolID, 127 | } 128 | 129 | return s.repo.Create(ctx, module) 130 | } 131 | 132 | func (s *ModulesService) Update(ctx context.Context, inp UpdateModuleInput) error { 133 | id, err := primitive.ObjectIDFromHex(inp.ID) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | schoolID, err := primitive.ObjectIDFromHex(inp.SchoolID) 139 | if err != nil { 140 | return err 141 | } 142 | 143 | updateInput := repository.UpdateModuleInput{ 144 | ID: id, 145 | SchoolID: schoolID, 146 | Name: inp.Name, 147 | Position: inp.Position, 148 | Published: inp.Published, 149 | } 150 | 151 | return s.repo.Update(ctx, updateInput) 152 | } 153 | 154 | func (s *ModulesService) Delete(ctx context.Context, schoolId, moduleId primitive.ObjectID) error { 155 | module, err := s.repo.GetById(ctx, moduleId) 156 | if err != nil { 157 | return err 158 | } 159 | 160 | if err := s.repo.Delete(ctx, schoolId, moduleId); err != nil { 161 | return err 162 | } 163 | 164 | lessonIds := make([]primitive.ObjectID, len(module.Lessons)) 165 | for _, lesson := range module.Lessons { 166 | lessonIds = append(lessonIds, lesson.ID) 167 | } 168 | 169 | return s.contentRepo.DeleteContent(ctx, schoolId, lessonIds) 170 | } 171 | 172 | func (s *ModulesService) DeleteByCourse(ctx context.Context, schoolId, courseId primitive.ObjectID) error { 173 | modules, err := s.repo.GetPublishedByCourseId(ctx, courseId) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | if err := s.repo.DeleteByCourse(ctx, schoolId, courseId); err != nil { 179 | return err 180 | } 181 | 182 | lessonIds := make([]primitive.ObjectID, 0) 183 | 184 | for _, module := range modules { 185 | for _, lesson := range module.Lessons { 186 | lessonIds = append(lessonIds, lesson.ID) 187 | } 188 | } 189 | 190 | return s.contentRepo.DeleteContent(ctx, schoolId, lessonIds) 191 | } 192 | 193 | func sortLessons(lessons []domain.Lesson) { 194 | sort.Slice(lessons, func(i, j int) bool { 195 | return lessons[i].Position < lessons[j].Position 196 | }) 197 | } 198 | -------------------------------------------------------------------------------- /internal/service/offers.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/domain" 7 | "github.com/zhashkevych/creatly-backend/internal/repository" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | ) 10 | 11 | type OffersService struct { 12 | repo repository.Offers 13 | modulesService Modules 14 | packagesService Packages 15 | } 16 | 17 | func NewOffersService(repo repository.Offers, modulesService Modules, packagesService Packages) *OffersService { 18 | return &OffersService{repo: repo, modulesService: modulesService, packagesService: packagesService} 19 | } 20 | 21 | func (s *OffersService) GetById(ctx context.Context, id primitive.ObjectID) (domain.Offer, error) { 22 | return s.repo.GetById(ctx, id) 23 | } 24 | 25 | func (s *OffersService) getByPackage(ctx context.Context, schoolId, packageId primitive.ObjectID) ([]domain.Offer, error) { 26 | offers, err := s.repo.GetBySchool(ctx, schoolId) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | result := make([]domain.Offer, 0) 32 | 33 | for _, offer := range offers { 34 | if inArray(offer.PackageIDs, packageId) { 35 | result = append(result, offer) 36 | } 37 | } 38 | 39 | return result, nil 40 | } 41 | 42 | func (s *OffersService) GetByModule(ctx context.Context, schoolId, moduleId primitive.ObjectID) ([]domain.Offer, error) { 43 | module, err := s.modulesService.GetById(ctx, moduleId) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return s.getByPackage(ctx, schoolId, module.PackageID) 49 | } 50 | 51 | func (s *OffersService) GetByCourse(ctx context.Context, courseId primitive.ObjectID) ([]domain.Offer, error) { 52 | packages, err := s.packagesService.GetByCourse(ctx, courseId) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | if len(packages) == 0 { 58 | return []domain.Offer{}, nil 59 | } 60 | 61 | packageIds := make([]primitive.ObjectID, len(packages)) 62 | for i, pkg := range packages { 63 | packageIds[i] = pkg.ID 64 | } 65 | 66 | return s.repo.GetByPackages(ctx, packageIds) 67 | } 68 | 69 | func (s *OffersService) Create(ctx context.Context, inp CreateOfferInput) (primitive.ObjectID, error) { 70 | if inp.PaymentMethod.UsesProvider { 71 | if err := inp.PaymentMethod.Validate(); err != nil { 72 | return primitive.ObjectID{}, err 73 | } 74 | } 75 | 76 | var ( 77 | packageIDs []primitive.ObjectID 78 | err error 79 | ) 80 | 81 | if inp.Packages != nil { 82 | packageIDs, err = stringArrayToObjectId(inp.Packages) 83 | if err != nil { 84 | return primitive.ObjectID{}, err 85 | } 86 | } 87 | 88 | return s.repo.Create(ctx, domain.Offer{ 89 | SchoolID: inp.SchoolID, 90 | Name: inp.Name, 91 | Description: inp.Description, 92 | Benefits: inp.Benefits, 93 | Price: inp.Price, 94 | PaymentMethod: inp.PaymentMethod, 95 | PackageIDs: packageIDs, 96 | }) 97 | } 98 | 99 | func (s *OffersService) GetAll(ctx context.Context, schoolId primitive.ObjectID) ([]domain.Offer, error) { 100 | return s.repo.GetBySchool(ctx, schoolId) 101 | } 102 | 103 | func (s *OffersService) Update(ctx context.Context, inp UpdateOfferInput) error { 104 | if err := inp.ValidatePayment(); err != nil { 105 | return err 106 | } 107 | 108 | id, err := primitive.ObjectIDFromHex(inp.ID) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | schoolId, err := primitive.ObjectIDFromHex(inp.SchoolID) 114 | if err != nil { 115 | return err 116 | } 117 | 118 | updateInput := repository.UpdateOfferInput{ 119 | ID: id, 120 | SchoolID: schoolId, 121 | Name: inp.Name, 122 | Description: inp.Description, 123 | Price: inp.Price, 124 | Benefits: inp.Benefits, 125 | PaymentMethod: inp.PaymentMethod, 126 | } 127 | 128 | if inp.Packages != nil { 129 | updateInput.Packages, err = stringArrayToObjectId(inp.Packages) 130 | if err != nil { 131 | return err 132 | } 133 | } 134 | 135 | return s.repo.Update(ctx, updateInput) 136 | } 137 | 138 | func (s *OffersService) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error { 139 | return s.repo.Delete(ctx, schoolId, id) 140 | } 141 | 142 | func (s *OffersService) GetByIds(ctx context.Context, ids []primitive.ObjectID) ([]domain.Offer, error) { 143 | return s.repo.GetByIds(ctx, ids) 144 | } 145 | 146 | func inArray(array []primitive.ObjectID, searchedItem primitive.ObjectID) bool { 147 | for i := range array { 148 | if array[i] == searchedItem { 149 | return true 150 | } 151 | } 152 | 153 | return false 154 | } 155 | -------------------------------------------------------------------------------- /internal/service/orders.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "github.com/zhashkevych/creatly-backend/internal/repository" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | type OrdersService struct { 13 | offersService Offers 14 | promoCodesService PromoCodes 15 | studentsService Students 16 | 17 | repo repository.Orders 18 | } 19 | 20 | func NewOrdersService(repo repository.Orders, offersService Offers, promoCodesService PromoCodes, studentsService Students) *OrdersService { 21 | return &OrdersService{ 22 | repo: repo, 23 | offersService: offersService, 24 | promoCodesService: promoCodesService, 25 | studentsService: studentsService, 26 | } 27 | } 28 | 29 | func (s *OrdersService) Create(ctx context.Context, studentId, offerId, promocodeId primitive.ObjectID) (primitive.ObjectID, error) { //nolint:funlen 30 | offer, err := s.offersService.GetById(ctx, offerId) 31 | if err != nil { 32 | return primitive.ObjectID{}, err 33 | } 34 | 35 | promocode, err := s.getOrderPromocode(ctx, offer.SchoolID, promocodeId) 36 | if err != nil { 37 | return primitive.ObjectID{}, err 38 | } 39 | 40 | student, err := s.studentsService.GetById(ctx, offer.SchoolID, studentId) 41 | if err != nil { 42 | return primitive.ObjectID{}, err 43 | } 44 | 45 | orderAmount := s.calculateOrderPrice(offer.Price.Value, promocode) 46 | 47 | id := primitive.NewObjectID() 48 | 49 | order := domain.Order{ 50 | ID: id, 51 | SchoolID: offer.SchoolID, 52 | Student: domain.StudentInfoShort{ 53 | ID: student.ID, 54 | Name: student.Name, 55 | Email: student.Email, 56 | }, 57 | Offer: domain.OrderOfferInfo{ 58 | ID: offer.ID, 59 | Name: offer.Name, 60 | }, 61 | Amount: orderAmount, 62 | Currency: offer.Price.Currency, 63 | CreatedAt: time.Now(), 64 | Status: domain.OrderStatusCreated, 65 | Transactions: make([]domain.Transaction, 0), 66 | } 67 | 68 | if !promocode.ID.IsZero() { 69 | order.Promo = domain.OrderPromoInfo{ 70 | ID: promocode.ID, 71 | Code: promocode.Code, 72 | } 73 | } 74 | 75 | err = s.repo.Create(ctx, order) 76 | 77 | return id, err 78 | } 79 | 80 | func (s *OrdersService) AddTransaction(ctx context.Context, id primitive.ObjectID, transaction domain.Transaction) (domain.Order, error) { 81 | return s.repo.AddTransaction(ctx, id, transaction) 82 | } 83 | 84 | func (s *OrdersService) GetBySchool(ctx context.Context, schoolId primitive.ObjectID, query domain.GetOrdersQuery) ([]domain.Order, int64, error) { 85 | return s.repo.GetBySchool(ctx, schoolId, query) 86 | } 87 | 88 | func (s *OrdersService) GetById(ctx context.Context, id primitive.ObjectID) (domain.Order, error) { 89 | return s.repo.GetById(ctx, id) 90 | } 91 | 92 | func (s *OrdersService) SetStatus(ctx context.Context, id primitive.ObjectID, status string) error { 93 | return s.repo.SetStatus(ctx, id, status) 94 | } 95 | 96 | func (s *OrdersService) getOrderPromocode(ctx context.Context, schoolId, promocodeId primitive.ObjectID) (domain.PromoCode, error) { 97 | var ( 98 | promocode domain.PromoCode 99 | err error 100 | ) 101 | 102 | if !promocodeId.IsZero() { 103 | promocode, err = s.promoCodesService.GetById(ctx, schoolId, promocodeId) 104 | if err != nil { 105 | return promocode, err 106 | } 107 | 108 | if promocode.ExpiresAt.Unix() < time.Now().Unix() { 109 | return promocode, domain.ErrPromocodeExpired 110 | } 111 | } 112 | 113 | return promocode, nil 114 | } 115 | 116 | func (s *OrdersService) calculateOrderPrice(price uint, promocode domain.PromoCode) uint { 117 | if promocode.ID.IsZero() { 118 | return price 119 | } else { 120 | return (price * uint(100-promocode.DiscountPercentage)) / 100 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /internal/service/packages.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/domain" 7 | "github.com/zhashkevych/creatly-backend/internal/repository" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | ) 10 | 11 | type PackagesService struct { 12 | repo repository.Packages 13 | modulesRepo repository.Modules 14 | } 15 | 16 | func NewPackagesService(repo repository.Packages, modulesRepo repository.Modules) *PackagesService { 17 | return &PackagesService{repo: repo, modulesRepo: modulesRepo} 18 | } 19 | 20 | func (s *PackagesService) Create(ctx context.Context, inp CreatePackageInput) (primitive.ObjectID, error) { 21 | courseId, err := primitive.ObjectIDFromHex(inp.CourseID) 22 | if err != nil { 23 | return primitive.ObjectID{}, err 24 | } 25 | 26 | schoolId, err := primitive.ObjectIDFromHex(inp.SchoolID) 27 | if err != nil { 28 | return primitive.ObjectID{}, err 29 | } 30 | 31 | id, err := s.repo.Create(ctx, domain.Package{ 32 | CourseID: courseId, 33 | SchoolID: schoolId, 34 | Name: inp.Name, 35 | }) 36 | 37 | if inp.Modules != nil { 38 | moduleIds, err := stringArrayToObjectId(inp.Modules) 39 | if err != nil { 40 | return primitive.ObjectID{}, err 41 | } 42 | 43 | if err := s.modulesRepo.AttachPackage(ctx, schoolId, id, moduleIds); err != nil { 44 | return primitive.ObjectID{}, err 45 | } 46 | } 47 | 48 | return id, err 49 | } 50 | 51 | func (s *PackagesService) GetByCourse(ctx context.Context, courseID primitive.ObjectID) ([]domain.Package, error) { 52 | pkgs, err := s.repo.GetByCourse(ctx, courseID) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | for i := range pkgs { 58 | modules, err := s.modulesRepo.GetByPackages(ctx, []primitive.ObjectID{pkgs[i].ID}) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | pkgs[i].Modules = modules 64 | } 65 | 66 | return pkgs, nil 67 | } 68 | 69 | func (s *PackagesService) GetById(ctx context.Context, id primitive.ObjectID) (domain.Package, error) { 70 | pkg, err := s.repo.GetById(ctx, id) 71 | if err != nil { 72 | return pkg, err 73 | } 74 | 75 | modules, err := s.modulesRepo.GetByPackages(ctx, []primitive.ObjectID{pkg.ID}) 76 | if err != nil { 77 | return pkg, err 78 | } 79 | 80 | pkg.Modules = modules 81 | 82 | return pkg, nil 83 | } 84 | 85 | func (s *PackagesService) GetByIds(ctx context.Context, ids []primitive.ObjectID) ([]domain.Package, error) { 86 | if len(ids) == 0 { 87 | return nil, nil 88 | } 89 | 90 | return s.repo.GetByIds(ctx, ids) 91 | } 92 | 93 | func (s *PackagesService) Update(ctx context.Context, inp UpdatePackageInput) error { 94 | id, err := primitive.ObjectIDFromHex(inp.ID) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | schoolId, err := primitive.ObjectIDFromHex(inp.SchoolID) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | if inp.Name != "" { 105 | if err := s.repo.Update(ctx, repository.UpdatePackageInput{ 106 | ID: id, 107 | SchoolID: schoolId, 108 | Name: inp.Name, 109 | }); err != nil { 110 | return err 111 | } 112 | } 113 | 114 | /* 115 | To update modules, that are a part of a package 116 | First we delete all modules from package and then we add new modules to the package 117 | */ 118 | if inp.Modules != nil { 119 | moduleIds, err := stringArrayToObjectId(inp.Modules) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | if err := s.modulesRepo.DetachPackageFromAll(ctx, schoolId, id); err != nil { 125 | return err 126 | } 127 | 128 | if err := s.modulesRepo.AttachPackage(ctx, schoolId, id, moduleIds); err != nil { 129 | return err 130 | } 131 | } 132 | 133 | return nil 134 | } 135 | 136 | func (s *PackagesService) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error { 137 | return s.repo.Delete(ctx, schoolId, id) 138 | } 139 | 140 | func stringArrayToObjectId(stringIds []string) ([]primitive.ObjectID, error) { 141 | var err error 142 | 143 | ids := make([]primitive.ObjectID, len(stringIds)) 144 | 145 | for i, id := range stringIds { 146 | ids[i], err = primitive.ObjectIDFromHex(id) 147 | if err != nil { 148 | return nil, err 149 | } 150 | } 151 | 152 | return ids, nil 153 | } 154 | -------------------------------------------------------------------------------- /internal/service/payments.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/zhashkevych/creatly-backend/internal/domain" 10 | "github.com/zhashkevych/creatly-backend/pkg/logger" 11 | "github.com/zhashkevych/creatly-backend/pkg/payment" 12 | "github.com/zhashkevych/creatly-backend/pkg/payment/fondy" 13 | "go.mongodb.org/mongo-driver/bson/primitive" 14 | ) 15 | 16 | const ( 17 | redirectURLTmpl = "https://%s/" // TODO: generate link with URL params for popup on frontend ? 18 | ) 19 | 20 | type PaymentsService struct { 21 | ordersService Orders 22 | offersService Offers 23 | studentsService Students 24 | emailService Emails 25 | schoolsService Schools 26 | 27 | fondyCallbackURL string 28 | } 29 | 30 | func NewPaymentsService(ordersService Orders, offersService Offers, studentsService Students, 31 | emailService Emails, schoolsService Schools, fondyCallbackURL string) *PaymentsService { 32 | return &PaymentsService{ 33 | ordersService: ordersService, 34 | offersService: offersService, 35 | studentsService: studentsService, 36 | emailService: emailService, 37 | schoolsService: schoolsService, 38 | fondyCallbackURL: fondyCallbackURL, 39 | } 40 | } 41 | 42 | func (s *PaymentsService) GeneratePaymentLink(ctx context.Context, orderId primitive.ObjectID) (string, error) { 43 | order, err := s.ordersService.GetById(ctx, orderId) 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | offer, err := s.offersService.GetById(ctx, order.Offer.ID) 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | if !offer.PaymentMethod.UsesProvider { 54 | return "", domain.ErrPaymentProviderNotUsed 55 | } 56 | 57 | paymentInput := payment.GeneratePaymentLinkInput{ 58 | OrderId: orderId.Hex(), 59 | Amount: order.Amount, 60 | Currency: offer.Price.Currency, 61 | OrderDesc: offer.Description, // TODO proper order description 62 | } 63 | 64 | switch offer.PaymentMethod.Provider { 65 | case domain.PaymentProviderFondy: 66 | return s.generateFondyPaymentLink(ctx, offer.SchoolID, paymentInput) 67 | default: 68 | return "", domain.ErrUnknownPaymentProvider 69 | } 70 | } 71 | 72 | func (s *PaymentsService) ProcessTransaction(ctx context.Context, callback interface{}) error { 73 | switch callbackData := callback.(type) { 74 | case fondy.Callback: 75 | return s.processFondyCallback(ctx, callbackData) 76 | default: 77 | return domain.ErrUnknownCallbackType 78 | } 79 | } 80 | 81 | func (s *PaymentsService) processFondyCallback(ctx context.Context, callback fondy.Callback) error { 82 | orderID, err := primitive.ObjectIDFromHex(callback.OrderId) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | order, err := s.ordersService.GetById(ctx, orderID) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | school, err := s.schoolsService.GetById(ctx, order.SchoolID) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | client, err := s.getFondyClient(school.Settings.Fondy) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | if err := client.ValidateCallback(callback); err != nil { 103 | return domain.ErrTransactionInvalid 104 | } 105 | 106 | transaction, err := createTransaction(callback) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | order, err = s.ordersService.AddTransaction(ctx, orderID, transaction) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | if transaction.Status != domain.OrderStatusPaid { 117 | return nil 118 | } 119 | 120 | offer, err := s.offersService.GetById(ctx, order.Offer.ID) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | if err := s.emailService.SendStudentPurchaseSuccessfulEmail(StudentPurchaseSuccessfulEmailInput{ 126 | Name: order.Student.Name, 127 | Email: order.Student.Email, 128 | CourseName: order.Offer.Name, 129 | }); err != nil { 130 | logger.Errorf("failed to send email after purchase: %s", err.Error()) 131 | } 132 | 133 | return s.studentsService.GiveAccessToOffer(ctx, order.Student.ID, offer) 134 | } 135 | 136 | func (s *PaymentsService) generateFondyPaymentLink(ctx context.Context, schoolId primitive.ObjectID, 137 | input payment.GeneratePaymentLinkInput) (string, error) { 138 | school, err := s.schoolsService.GetById(ctx, schoolId) 139 | if err != nil { 140 | return "", err 141 | } 142 | 143 | client, err := s.getFondyClient(school.Settings.Fondy) 144 | if err != nil { 145 | return "", err 146 | } 147 | 148 | input.CallbackURL = s.fondyCallbackURL 149 | input.RedirectURL = getRedirectURL(school.Settings.GetDomain()) 150 | 151 | logger.Infof("%+v", input) 152 | 153 | return client.GeneratePaymentLink(input) 154 | } 155 | 156 | func createTransaction(callbackData fondy.Callback) (domain.Transaction, error) { 157 | var status string 158 | if callbackData.PaymentApproved() { 159 | status = domain.OrderStatusPaid 160 | } else { 161 | status = domain.OrderStatusOther 162 | } 163 | 164 | if !callbackData.Success() { 165 | status = domain.OrderStatusFailed 166 | } 167 | 168 | additionalInfo, err := json.Marshal(callbackData) 169 | if err != nil { 170 | return domain.Transaction{}, err 171 | } 172 | 173 | return domain.Transaction{ 174 | Status: status, 175 | CreatedAt: time.Now(), 176 | AdditionalInfo: string(additionalInfo), 177 | }, nil 178 | } 179 | 180 | func (s *PaymentsService) getFondyClient(fondyConnectionInfo domain.Fondy) (*fondy.Client, error) { 181 | if !fondyConnectionInfo.Connected { 182 | return nil, domain.ErrFondyIsNotConnected 183 | } 184 | 185 | return fondy.NewFondyClient(fondyConnectionInfo.MerchantID, fondyConnectionInfo.MerchantPassword), nil 186 | } 187 | 188 | func getRedirectURL(domain string) string { 189 | return fmt.Sprintf(redirectURLTmpl, domain) 190 | } 191 | -------------------------------------------------------------------------------- /internal/service/promocodes.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "github.com/zhashkevych/creatly-backend/internal/repository" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | type PromoCodeService struct { 13 | repo repository.PromoCodes 14 | } 15 | 16 | func NewPromoCodeService(repo repository.PromoCodes) *PromoCodeService { 17 | return &PromoCodeService{repo: repo} 18 | } 19 | 20 | func (s *PromoCodeService) Create(ctx context.Context, inp CreatePromoCodeInput) (primitive.ObjectID, error) { 21 | return s.repo.Create(ctx, domain.PromoCode{ 22 | SchoolID: inp.SchoolID, 23 | Code: inp.Code, 24 | DiscountPercentage: inp.DiscountPercentage, 25 | ExpiresAt: inp.ExpiresAt, 26 | OfferIDs: inp.OfferIDs, 27 | }) 28 | } 29 | 30 | func (s *PromoCodeService) Update(ctx context.Context, inp domain.UpdatePromoCodeInput) error { 31 | return s.repo.Update(ctx, inp) 32 | } 33 | 34 | func (s *PromoCodeService) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error { 35 | return s.repo.Delete(ctx, schoolId, id) 36 | } 37 | 38 | func (s *PromoCodeService) GetByCode(ctx context.Context, schoolId primitive.ObjectID, code string) (domain.PromoCode, error) { 39 | promo, err := s.repo.GetByCode(ctx, schoolId, code) 40 | if err != nil { 41 | if errors.Is(err, domain.ErrPromoNotFound) { 42 | return domain.PromoCode{}, err 43 | } 44 | 45 | return domain.PromoCode{}, err 46 | } 47 | 48 | return promo, nil 49 | } 50 | 51 | func (s *PromoCodeService) GetById(ctx context.Context, schoolId, id primitive.ObjectID) (domain.PromoCode, error) { 52 | promo, err := s.repo.GetById(ctx, schoolId, id) 53 | if err != nil { 54 | if errors.Is(err, domain.ErrPromoNotFound) { 55 | return domain.PromoCode{}, err 56 | } 57 | 58 | return domain.PromoCode{}, err 59 | } 60 | 61 | return promo, nil 62 | } 63 | 64 | func (s *PromoCodeService) GetBySchool(ctx context.Context, schoolId primitive.ObjectID) ([]domain.PromoCode, error) { 65 | return s.repo.GetBySchool(ctx, schoolId) 66 | } 67 | -------------------------------------------------------------------------------- /internal/service/schools.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zhashkevych/creatly-backend/pkg/payment" 7 | "github.com/zhashkevych/creatly-backend/pkg/payment/fondy" 8 | 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | 11 | "github.com/zhashkevych/creatly-backend/internal/domain" 12 | "github.com/zhashkevych/creatly-backend/internal/repository" 13 | "github.com/zhashkevych/creatly-backend/pkg/cache" 14 | ) 15 | 16 | type SchoolsService struct { 17 | repo repository.Schools 18 | cache cache.Cache 19 | ttl int64 20 | } 21 | 22 | func NewSchoolsService(repo repository.Schools, cache cache.Cache, ttl int64) *SchoolsService { 23 | return &SchoolsService{repo: repo, cache: cache, ttl: ttl} 24 | } 25 | 26 | func (s *SchoolsService) Create(ctx context.Context, name string) (primitive.ObjectID, error) { 27 | return s.repo.Create(ctx, name) 28 | } 29 | 30 | func (s *SchoolsService) GetByDomain(ctx context.Context, domainName string) (domain.School, error) { 31 | if value, err := s.cache.Get(domainName); err == nil { 32 | return value.(domain.School), nil 33 | } 34 | 35 | school, err := s.repo.GetByDomain(ctx, domainName) 36 | if err != nil { 37 | return domain.School{}, err 38 | } 39 | 40 | err = s.cache.Set(domainName, school, s.ttl) 41 | 42 | return school, err 43 | } 44 | 45 | func (s *SchoolsService) GetById(ctx context.Context, id primitive.ObjectID) (domain.School, error) { 46 | return s.repo.GetById(ctx, id) 47 | } 48 | 49 | func (s *SchoolsService) UpdateSettings(ctx context.Context, schoolId primitive.ObjectID, inp domain.UpdateSchoolSettingsInput) error { 50 | return s.repo.UpdateSettings(ctx, schoolId, inp) 51 | } 52 | 53 | func (s *SchoolsService) ConnectFondy(ctx context.Context, input ConnectFondyInput) error { 54 | client := fondy.NewFondyClient(input.MerchantID, input.MerchantPassword) 55 | 56 | id := primitive.NewObjectID() 57 | 58 | _, err := client.GeneratePaymentLink(payment.GeneratePaymentLinkInput{ 59 | OrderId: id.Hex(), 60 | Amount: 1000, 61 | Currency: "USD", 62 | OrderDesc: "CREATLY - TESTING FONDY CREDENTIALS", 63 | }) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | creds := domain.Fondy{ 69 | MerchantPassword: input.MerchantPassword, 70 | MerchantID: input.MerchantID, 71 | Connected: true, 72 | } 73 | 74 | return s.repo.SetFondyCredentials(ctx, input.SchoolID, creds) 75 | } 76 | 77 | func (s *SchoolsService) ConnectSendPulse(ctx context.Context, input ConnectSendPulseInput) error { 78 | // todo 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/service/student_lessons.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/repository" 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | ) 9 | 10 | type StudentLessonsService struct { 11 | repo repository.StudentLessons 12 | } 13 | 14 | func NewStudentLessonsService(repo repository.StudentLessons) *StudentLessonsService { 15 | return &StudentLessonsService{ 16 | repo: repo, 17 | } 18 | } 19 | 20 | func (s *StudentLessonsService) AddFinished(ctx context.Context, studentID, lessonID primitive.ObjectID) error { 21 | return s.repo.AddFinished(ctx, studentID, lessonID) 22 | } 23 | 24 | func (s *StudentLessonsService) SetLastOpened(ctx context.Context, studentID, lessonID primitive.ObjectID) error { 25 | return s.repo.SetLastOpened(ctx, studentID, lessonID) 26 | } 27 | -------------------------------------------------------------------------------- /internal/service/surveys.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhashkevych/creatly-backend/internal/domain" 8 | "github.com/zhashkevych/creatly-backend/internal/repository" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | type SurveysService struct { 13 | modulesRepo repository.Modules 14 | surveyResultsRepo repository.SurveyResults 15 | studentsRepo repository.Students 16 | } 17 | 18 | func NewSurveysService(modulesRepo repository.Modules, surveyResultsRepo repository.SurveyResults, studentsRepo repository.Students) *SurveysService { 19 | return &SurveysService{modulesRepo: modulesRepo, surveyResultsRepo: surveyResultsRepo, studentsRepo: studentsRepo} 20 | } 21 | 22 | func (s *SurveysService) Create(ctx context.Context, inp CreateSurveyInput) error { 23 | for i := range inp.Survey.Questions { 24 | inp.Survey.Questions[i].ID = primitive.NewObjectID() 25 | } 26 | 27 | return s.modulesRepo.AttachSurvey(ctx, inp.SchoolID, inp.ModuleID, inp.Survey) 28 | } 29 | 30 | func (s *SurveysService) Delete(ctx context.Context, schoolId, moduleId primitive.ObjectID) error { 31 | return s.modulesRepo.DetachSurvey(ctx, schoolId, moduleId) 32 | } 33 | 34 | func (s *SurveysService) SaveStudentAnswers(ctx context.Context, inp SaveStudentAnswersInput) error { 35 | student, err := s.studentsRepo.GetById(ctx, inp.SchoolID, inp.StudentID) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | return s.surveyResultsRepo.Save(ctx, domain.SurveyResult{ 41 | Student: domain.StudentInfoShort{ 42 | ID: student.ID, 43 | Name: student.Name, 44 | Email: student.Email, 45 | }, 46 | ModuleID: inp.ModuleID, 47 | SubmittedAt: time.Now(), 48 | Answers: inp.Answers, 49 | }) 50 | } 51 | 52 | func (s *SurveysService) GetResultsByModule(ctx context.Context, moduleId primitive.ObjectID, 53 | pagination *domain.PaginationQuery) ([]domain.SurveyResult, int64, error) { 54 | return s.surveyResultsRepo.GetAllByModule(ctx, moduleId, pagination) 55 | } 56 | 57 | func (s *SurveysService) GetStudentResults(ctx context.Context, moduleID, studentID primitive.ObjectID) (domain.SurveyResult, error) { 58 | return s.surveyResultsRepo.GetByStudent(ctx, moduleID, studentID) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/auth/manager.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math/rand" 7 | "time" 8 | 9 | "github.com/dgrijalva/jwt-go" 10 | ) 11 | 12 | // TokenManager provides logic for JWT & Refresh tokens generation and parsing. 13 | type TokenManager interface { 14 | NewJWT(userId string, ttl time.Duration) (string, error) 15 | Parse(accessToken string) (string, error) 16 | NewRefreshToken() (string, error) 17 | } 18 | 19 | type Manager struct { 20 | signingKey string 21 | } 22 | 23 | func NewManager(signingKey string) (*Manager, error) { 24 | if signingKey == "" { 25 | return nil, errors.New("empty signing key") 26 | } 27 | 28 | return &Manager{signingKey: signingKey}, nil 29 | } 30 | 31 | func (m *Manager) NewJWT(userId string, ttl time.Duration) (string, error) { 32 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{ 33 | ExpiresAt: time.Now().Add(ttl).Unix(), 34 | Subject: userId, 35 | }) 36 | 37 | return token.SignedString([]byte(m.signingKey)) 38 | } 39 | 40 | func (m *Manager) Parse(accessToken string) (string, error) { 41 | token, err := jwt.Parse(accessToken, func(token *jwt.Token) (i interface{}, err error) { 42 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 43 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 44 | } 45 | 46 | return []byte(m.signingKey), nil 47 | }) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | claims, ok := token.Claims.(jwt.MapClaims) 53 | if !ok { 54 | return "", fmt.Errorf("error get user claims from token") 55 | } 56 | 57 | return claims["sub"].(string), nil 58 | } 59 | 60 | func (m *Manager) NewRefreshToken() (string, error) { 61 | b := make([]byte, 32) 62 | 63 | s := rand.NewSource(time.Now().Unix()) 64 | r := rand.New(s) 65 | 66 | if _, err := r.Read(b); err != nil { 67 | return "", err 68 | } 69 | 70 | return fmt.Sprintf("%x", b), nil 71 | } 72 | -------------------------------------------------------------------------------- /pkg/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | type Cache interface { 4 | Set(key, value interface{}, ttl int64) error 5 | Get(key interface{}) (interface{}, error) 6 | } 7 | -------------------------------------------------------------------------------- /pkg/cache/memory.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | var ErrItemNotFound = errors.New("cache: item not found") 10 | 11 | type item struct { 12 | value interface{} 13 | createdAt int64 14 | ttl int64 15 | } 16 | 17 | type MemoryCache struct { 18 | cache map[interface{}]*item 19 | sync.RWMutex 20 | } 21 | 22 | // NewMemoryCache uses map to store key:value data in-memory. 23 | func NewMemoryCache() *MemoryCache { 24 | c := &MemoryCache{cache: make(map[interface{}]*item)} 25 | go c.setTtlTimer() 26 | 27 | return c 28 | } 29 | 30 | func (c *MemoryCache) setTtlTimer() { 31 | for { 32 | c.Lock() 33 | for k, v := range c.cache { 34 | if time.Now().Unix()-v.createdAt > v.ttl { 35 | delete(c.cache, k) 36 | } 37 | } 38 | c.Unlock() 39 | 40 | <-time.After(time.Second) 41 | } 42 | } 43 | 44 | func (c *MemoryCache) Set(key, value interface{}, ttl int64) error { 45 | c.Lock() 46 | c.cache[key] = &item{ 47 | value: value, 48 | createdAt: time.Now().Unix(), 49 | ttl: ttl, 50 | } 51 | c.Unlock() 52 | 53 | return nil 54 | } 55 | 56 | func (c *MemoryCache) Get(key interface{}) (interface{}, error) { 57 | c.RLock() 58 | item, ex := c.cache[key] 59 | c.RUnlock() 60 | 61 | if !ex { 62 | return nil, ErrItemNotFound 63 | } 64 | 65 | return item.value, nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/database/mongodb/mongodb.go: -------------------------------------------------------------------------------- 1 | package mongodb 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "go.mongodb.org/mongo-driver/mongo" 9 | "go.mongodb.org/mongo-driver/mongo/options" 10 | ) 11 | 12 | const timeout = 10 * time.Second 13 | 14 | // NewClient established connection to a mongoDb instance using provided URI and auth credentials. 15 | func NewClient(uri, username, password string) (*mongo.Client, error) { 16 | opts := options.Client().ApplyURI(uri) 17 | if username != "" && password != "" { 18 | opts.SetAuth(options.Credential{ 19 | Username: username, Password: password, 20 | }) 21 | } 22 | 23 | client, err := mongo.NewClient(opts) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 29 | defer cancel() 30 | 31 | err = client.Connect(ctx) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | err = client.Ping(context.Background(), nil) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return client, nil 42 | } 43 | 44 | func IsDuplicate(err error) bool { 45 | var e mongo.WriteException 46 | if errors.As(err, &e) { 47 | for _, we := range e.WriteErrors { 48 | if we.Code == 11000 { 49 | return true 50 | } 51 | } 52 | } 53 | 54 | return false 55 | } 56 | -------------------------------------------------------------------------------- /pkg/dns/dns.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/cloudflare/cloudflare-go" 7 | ) 8 | 9 | type DomainManager interface { 10 | AddCNAMERecord(ctx context.Context, subdomain string) error 11 | } 12 | 13 | type Service struct { 14 | client *cloudflare.API 15 | email string 16 | cnameTarget string 17 | } 18 | 19 | func NewService(client *cloudflare.API, email, cnameTarget string) *Service { 20 | return &Service{ 21 | client: client, 22 | email: email, 23 | cnameTarget: cnameTarget, 24 | } 25 | } 26 | 27 | func (s *Service) AddCNAMERecord(ctx context.Context, subdomain string) error { 28 | id, err := s.client.ZoneIDByName(s.email) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | // todo enable proxy 34 | proxied := true 35 | _, err = s.client.CreateDNSRecord(ctx, id, cloudflare.DNSRecord{ 36 | Name: subdomain, 37 | Type: "CNAME", 38 | Content: s.cnameTarget, 39 | TTL: 1, 40 | Proxied: &proxied, 41 | }) 42 | 43 | return err 44 | } 45 | -------------------------------------------------------------------------------- /pkg/email/mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock_email 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | "github.com/zhashkevych/creatly-backend/pkg/email" 6 | ) 7 | 8 | type EmailProvider struct { 9 | mock.Mock 10 | } 11 | 12 | func (m *EmailProvider) AddEmailToList(inp email.AddEmailInput) error { 13 | args := m.Called(inp) 14 | 15 | return args.Error(0) 16 | } 17 | 18 | type EmailSender struct { 19 | mock.Mock 20 | } 21 | 22 | func (m *EmailSender) Send(inp email.SendEmailInput) error { 23 | args := m.Called(inp) 24 | 25 | return args.Error(0) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/email/provider.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | type AddEmailInput struct { 4 | Email string 5 | ListID string 6 | Variables map[string]string 7 | } 8 | 9 | type Provider interface { 10 | AddEmailToList(AddEmailInput) error 11 | } 12 | -------------------------------------------------------------------------------- /pkg/email/sender.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "html/template" 7 | 8 | "github.com/zhashkevych/creatly-backend/pkg/logger" 9 | ) 10 | 11 | type SendEmailInput struct { 12 | To string 13 | Subject string 14 | Body string 15 | } 16 | 17 | type Sender interface { 18 | Send(input SendEmailInput) error 19 | } 20 | 21 | func (e *SendEmailInput) GenerateBodyFromHTML(templateFileName string, data interface{}) error { 22 | t, err := template.ParseFiles(templateFileName) 23 | if err != nil { 24 | logger.Errorf("failed to parse file %s:%s", templateFileName, err.Error()) 25 | 26 | return err 27 | } 28 | 29 | buf := new(bytes.Buffer) 30 | if err = t.Execute(buf, data); err != nil { 31 | return err 32 | } 33 | 34 | e.Body = buf.String() 35 | 36 | return nil 37 | } 38 | 39 | func (e *SendEmailInput) Validate() error { 40 | if e.To == "" { 41 | return errors.New("empty to") 42 | } 43 | 44 | if e.Subject == "" || e.Body == "" { 45 | return errors.New("empty subject/body") 46 | } 47 | 48 | if !IsEmailValid(e.To) { 49 | return errors.New("invalid to email") 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/email/sendpulse/client.go: -------------------------------------------------------------------------------- 1 | package sendpulse 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | 11 | "github.com/zhashkevych/creatly-backend/pkg/cache" 12 | "github.com/zhashkevych/creatly-backend/pkg/email" 13 | "github.com/zhashkevych/creatly-backend/pkg/logger" 14 | ) 15 | 16 | // Documentation https://sendpulse.com/integrations/api 17 | // Note: The request limit is 10 requests per second. 18 | 19 | const ( 20 | endpoint = "https://api.sendpulse.com" 21 | authorizeEndpoint = "/oauth/access_token" 22 | addToListEndpoint = "/addressbooks/%s/emails" // addressbooks/{id}/emails 23 | 24 | grantType = "client_credentials" 25 | 26 | cacheTTL = 3600 // In seconds. SendPulse access tokens are valid for 1 hour 27 | ) 28 | 29 | type authRequest struct { 30 | GrantType string `json:"grant_type"` 31 | ClientID string `json:"client_id"` 32 | ClientSecret string `json:"client_secret"` 33 | } 34 | 35 | type authResponse struct { 36 | AccessToken string `json:"access_token"` 37 | TokenType string `json:"token_type"` 38 | ExpiresIn int `json:"expires_in"` 39 | } 40 | 41 | type addToListRequest struct { 42 | Emails []emailInfo `json:"emails"` 43 | } 44 | 45 | type emailInfo struct { 46 | Email string `json:"email"` 47 | Variables map[string]string `json:"variables"` 48 | } 49 | 50 | // Client is SendPulse API client implementation. 51 | type Client struct { 52 | id string 53 | secret string 54 | 55 | cache cache.Cache 56 | } 57 | 58 | func NewClient(id, secret string, cache cache.Cache) *Client { 59 | return &Client{id: id, secret: secret, cache: cache} 60 | } 61 | 62 | // AddEmailToList adds lead to provided email list with specific variables. 63 | func (c *Client) AddEmailToList(input email.AddEmailInput) error { 64 | token, err := c.getToken() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | reqData := addToListRequest{ 70 | Emails: []emailInfo{ 71 | { 72 | Email: input.Email, 73 | Variables: input.Variables, 74 | }, 75 | }, 76 | } 77 | 78 | reqBody, err := json.Marshal(reqData) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | path := fmt.Sprintf(addToListEndpoint, input.ListID) 84 | 85 | req, err := http.NewRequest(http.MethodPost, endpoint+path, bytes.NewBuffer(reqBody)) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | req.Header.Set("Content-Type", "application/json") 91 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 92 | 93 | resp, err := http.DefaultClient.Do(req) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | defer resp.Body.Close() 99 | 100 | body, err := ioutil.ReadAll(resp.Body) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | logger.Infof("SendPulse response: %s", string(body)) 106 | 107 | if resp.StatusCode != 200 { 108 | return errors.New("status code is not OK") 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func (c *Client) getToken() (string, error) { 115 | // todo set unique key (by schoolId) 116 | token, err := c.cache.Get("t") 117 | if err == nil { 118 | return token.(string), nil 119 | } 120 | 121 | token, err = c.authenticate() 122 | if err != nil { 123 | return "", err 124 | } 125 | 126 | err = c.cache.Set("t", token, cacheTTL) 127 | 128 | return token.(string), err 129 | } 130 | 131 | func (c *Client) authenticate() (string, error) { 132 | reqData := authRequest{ 133 | GrantType: grantType, 134 | ClientID: c.id, 135 | ClientSecret: c.secret, 136 | } 137 | 138 | reqBody, err := json.Marshal(reqData) 139 | if err != nil { 140 | return "", err 141 | } 142 | 143 | resp, err := http.Post(endpoint+authorizeEndpoint, "application/json", bytes.NewBuffer(reqBody)) 144 | if err != nil { 145 | return "", err 146 | } 147 | defer resp.Body.Close() 148 | 149 | if resp.StatusCode != 200 { 150 | return "", errors.New("status code is not OK") 151 | } 152 | 153 | var respData authResponse 154 | 155 | respBody, err := ioutil.ReadAll(resp.Body) 156 | if err != nil { 157 | return "", err 158 | } 159 | 160 | err = json.Unmarshal(respBody, &respData) 161 | if err != nil { 162 | return "", err 163 | } 164 | 165 | return respData.AccessToken, nil 166 | } 167 | -------------------------------------------------------------------------------- /pkg/email/smtp/smtp.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | "github.com/go-gomail/gomail" 5 | "github.com/pkg/errors" 6 | "github.com/zhashkevych/creatly-backend/pkg/email" 7 | ) 8 | 9 | type SMTPSender struct { 10 | from string 11 | pass string 12 | host string 13 | port int 14 | } 15 | 16 | func NewSMTPSender(from, pass, host string, port int) (*SMTPSender, error) { 17 | if !email.IsEmailValid(from) { 18 | return nil, errors.New("invalid from email") 19 | } 20 | 21 | return &SMTPSender{from: from, pass: pass, host: host, port: port}, nil 22 | } 23 | 24 | func (s *SMTPSender) Send(input email.SendEmailInput) error { 25 | if err := input.Validate(); err != nil { 26 | return err 27 | } 28 | 29 | msg := gomail.NewMessage() 30 | msg.SetHeader("From", s.from) 31 | msg.SetHeader("To", input.To) 32 | msg.SetHeader("Subject", input.Subject) 33 | msg.SetBody("text/html", input.Body) 34 | 35 | dialer := gomail.NewDialer(s.host, s.port, s.from, s.pass) 36 | if err := dialer.DialAndSend(msg); err != nil { 37 | return errors.Wrap(err, "failed to sent email via smtp") 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /pkg/email/validate.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import "regexp" 4 | 5 | const ( 6 | minEmailLen = 3 7 | maxEmailLen = 255 8 | ) 9 | 10 | var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 11 | 12 | func IsEmailValid(email string) bool { 13 | if len(email) < minEmailLen || len(email) > maxEmailLen { 14 | return false 15 | } 16 | 17 | return emailRegex.MatchString(email) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/hash/password.go: -------------------------------------------------------------------------------- 1 | package hash 2 | 3 | import ( 4 | "crypto/sha1" 5 | "fmt" 6 | ) 7 | 8 | // PasswordHasher provides hashing logic to securely store passwords. 9 | type PasswordHasher interface { 10 | Hash(password string) (string, error) 11 | } 12 | 13 | // SHA1Hasher uses SHA1 to hash passwords with provided salt. 14 | type SHA1Hasher struct { 15 | salt string 16 | } 17 | 18 | func NewSHA1Hasher(salt string) *SHA1Hasher { 19 | return &SHA1Hasher{salt: salt} 20 | } 21 | 22 | // Hash creates SHA1 hash of given password. 23 | func (h *SHA1Hasher) Hash(password string) (string, error) { 24 | hash := sha1.New() 25 | 26 | if _, err := hash.Write([]byte(password)); err != nil { 27 | return "", err 28 | } 29 | 30 | return fmt.Sprintf("%x", hash.Sum([]byte(h.salt))), nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/limiter/limiter.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "sync" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/zhashkevych/creatly-backend/pkg/logger" 11 | "golang.org/x/time/rate" 12 | ) 13 | 14 | // visitor holds limiter and lastSeen for specific user. 15 | type visitor struct { 16 | limiter *rate.Limiter 17 | lastSeen time.Time 18 | } 19 | 20 | // rateLimiter used to rate limit an incoming requests. 21 | type rateLimiter struct { 22 | sync.RWMutex 23 | 24 | visitors map[string]*visitor 25 | limit rate.Limit 26 | burst int 27 | ttl time.Duration 28 | } 29 | 30 | // newRateLimiter creates an instance of the rateLimiter. 31 | func newRateLimiter(rps, burst int, ttl time.Duration) *rateLimiter { 32 | return &rateLimiter{ 33 | visitors: make(map[string]*visitor), 34 | limit: rate.Limit(rps), 35 | burst: burst, 36 | ttl: ttl, 37 | } 38 | } 39 | 40 | // getVisitor returns limiter for the specific visitor by its IP, 41 | // looking up within the visitors map. 42 | func (l *rateLimiter) getVisitor(ip string) *rate.Limiter { 43 | l.RLock() 44 | v, exists := l.visitors[ip] 45 | l.RUnlock() 46 | 47 | if !exists { 48 | limiter := rate.NewLimiter(l.limit, l.burst) 49 | l.Lock() 50 | l.visitors[ip] = &visitor{limiter, time.Now()} 51 | l.Unlock() 52 | 53 | return limiter 54 | } 55 | 56 | v.lastSeen = time.Now() 57 | 58 | return v.limiter 59 | } 60 | 61 | // cleanupVisitors removes old entries from the visitors map. 62 | func (l *rateLimiter) cleanupVisitors() { 63 | for { 64 | time.Sleep(time.Minute) 65 | 66 | l.Lock() 67 | for ip, v := range l.visitors { 68 | if time.Since(v.lastSeen) > l.ttl { 69 | delete(l.visitors, ip) 70 | } 71 | } 72 | l.Unlock() 73 | } 74 | } 75 | 76 | // Limit creates a new rate limiter middleware handler. 77 | func Limit(rps int, burst int, ttl time.Duration) gin.HandlerFunc { 78 | l := newRateLimiter(rps, burst, ttl) 79 | 80 | // run a background worker to clean up old entries 81 | go l.cleanupVisitors() 82 | 83 | return func(c *gin.Context) { 84 | ip, _, err := net.SplitHostPort(c.Request.RemoteAddr) 85 | if err != nil { 86 | logger.Error(err) 87 | c.AbortWithStatus(http.StatusInternalServerError) 88 | 89 | return 90 | } 91 | 92 | if !l.getVisitor(ip).Allow() { 93 | c.AbortWithStatus(http.StatusTooManyRequests) 94 | 95 | return 96 | } 97 | 98 | c.Next() 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | type Logger interface { 4 | Debug(msg string, params map[string]interface{}) 5 | Info(msg string, params map[string]interface{}) 6 | Warn(msg string, params map[string]interface{}) 7 | Error(msg string, params map[string]interface{}) 8 | } 9 | -------------------------------------------------------------------------------- /pkg/logger/logrus.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | ) 6 | 7 | // TODO create general interface with generic fields 8 | 9 | func Debug(msg ...interface{}) { 10 | logrus.Debug(msg...) 11 | } 12 | 13 | func Debugf(format string, args ...interface{}) { 14 | logrus.Debugf(format, args...) 15 | } 16 | 17 | func Info(msg ...interface{}) { 18 | logrus.Info(msg...) 19 | } 20 | 21 | func Infof(format string, args ...interface{}) { 22 | logrus.Infof(format, args...) 23 | } 24 | 25 | func Warn(msg ...interface{}) { 26 | logrus.Warn(msg...) 27 | } 28 | 29 | func Warnf(format string, args ...interface{}) { 30 | logrus.Warnf(format, args...) 31 | } 32 | 33 | func Error(msg ...interface{}) { 34 | logrus.Error(msg...) 35 | } 36 | 37 | func Errorf(format string, args ...interface{}) { 38 | logrus.Errorf(format, args...) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/otp/mock.go: -------------------------------------------------------------------------------- 1 | package otp 2 | 3 | import "github.com/stretchr/testify/mock" 4 | 5 | type MockGenerator struct { 6 | mock.Mock 7 | } 8 | 9 | func (m *MockGenerator) RandomSecret(length int) string { 10 | args := m.Called(length) 11 | 12 | return args.Get(0).(string) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/otp/otp.go: -------------------------------------------------------------------------------- 1 | package otp 2 | 3 | import "github.com/xlzd/gotp" 4 | 5 | type Generator interface { 6 | RandomSecret(length int) string 7 | } 8 | 9 | type GOTPGenerator struct{} 10 | 11 | func NewGOTPGenerator() *GOTPGenerator { 12 | return &GOTPGenerator{} 13 | } 14 | 15 | func (g *GOTPGenerator) RandomSecret(length int) string { 16 | return gotp.RandomSecret(length) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/payment/provider.go: -------------------------------------------------------------------------------- 1 | package payment 2 | 3 | type GeneratePaymentLinkInput struct { 4 | OrderId string 5 | Amount uint 6 | Currency string 7 | OrderDesc string 8 | CallbackURL string 9 | RedirectURL string 10 | } 11 | 12 | type Provider interface { 13 | GeneratePaymentLink(input GeneratePaymentLinkInput) (string, error) 14 | ValidateCallback(input interface{}) error 15 | } 16 | -------------------------------------------------------------------------------- /pkg/storage/minio.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/minio/minio-go/v7" 8 | ) 9 | 10 | type FileStorage struct { 11 | client *minio.Client 12 | bucket string 13 | endpoint string 14 | } 15 | 16 | func NewFileStorage(client *minio.Client, bucket, endpoint string) *FileStorage { 17 | return &FileStorage{ 18 | client: client, 19 | bucket: bucket, 20 | endpoint: endpoint, 21 | } 22 | } 23 | 24 | func (fs *FileStorage) Upload(ctx context.Context, input UploadInput) (string, error) { 25 | opts := minio.PutObjectOptions{ 26 | ContentType: input.ContentType, 27 | UserMetadata: map[string]string{"x-amz-acl": "public-read"}, 28 | } 29 | 30 | _, err := fs.client.PutObject(ctx, fs.bucket, input.Name, input.File, input.Size, opts) 31 | if err != nil { 32 | return "", err 33 | } 34 | 35 | return fs.generateFileURL(input.Name), nil 36 | } 37 | 38 | // DigitalOcean Spaces URL format. 39 | func (fs *FileStorage) generateFileURL(filename string) string { 40 | return fmt.Sprintf("https://%s.%s/%s", fs.bucket, fs.endpoint, filename) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | type UploadInput struct { 9 | File io.Reader 10 | Name string 11 | Size int64 12 | ContentType string 13 | } 14 | 15 | type Provider interface { 16 | Upload(ctx context.Context, input UploadInput) (string, error) 17 | } 18 | -------------------------------------------------------------------------------- /templates/purchase_successful.html: -------------------------------------------------------------------------------- 1 |

{{.Name}}, спасибо большое за покупку "{{.CourseName}}"!

2 |
3 |

Надеюсь данный материал будет тебе полезен и интересен!

4 |

Если у тебя возникают вопросы или ты хочешь поделиться своим отзывом - пиши мне письмо на zhashkevychmaksim@gmail.com.

5 |

Мне крайне важен твой отзыв, чтобы улучшать материалы и делать курс максимально полезным!

6 | 7 |

8 | 9 |

С уважением, Максим

-------------------------------------------------------------------------------- /templates/verification_email.html: -------------------------------------------------------------------------------- 1 |

Спасибо за регистрацию!

2 |
3 |

Чтобы подтвердить свой аккаунт, переходи по ссылке.

-------------------------------------------------------------------------------- /tests/admins_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/zhashkevych/creatly-backend/internal/domain" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | ) 14 | 15 | func (s *APITestSuite) TestAdminCreateCourse() { 16 | router := gin.New() 17 | s.handler.Init(router.Group("/api")) 18 | r := s.Require() 19 | 20 | // populate DB data 21 | id := primitive.NewObjectID() 22 | schoolID := primitive.NewObjectID() 23 | adminEmail, password := "testAdmin@test.com", "qwerty123" 24 | passwordHash, err := s.hasher.Hash(password) 25 | s.NoError(err) 26 | 27 | _, err = s.db.Collection("admins").InsertOne(context.Background(), domain.Admin{ 28 | ID: id, 29 | Email: adminEmail, 30 | Password: passwordHash, 31 | SchoolID: schoolID, 32 | }) 33 | s.NoError(err) 34 | 35 | jwt, err := s.getJwt(id) 36 | s.NoError(err) 37 | 38 | adminCourseName := "admin course test name" 39 | 40 | name := fmt.Sprintf(`{"name":"%s"}`, adminCourseName) 41 | 42 | req, _ := http.NewRequest("POST", "/api/v1/admins/courses", strings.NewReader(name)) 43 | req.Header.Set("Content-type", "application/json") 44 | req.Header.Set("Authorization", "Bearer "+jwt) 45 | 46 | resp := httptest.NewRecorder() 47 | router.ServeHTTP(resp, req) 48 | 49 | r.Equal(http.StatusCreated, resp.Result().StatusCode) 50 | } 51 | 52 | func (s *APITestSuite) TestAdminGetAllCourses() { 53 | router := gin.New() 54 | s.handler.Init(router.Group("/api")) 55 | r := s.Require() 56 | 57 | id := primitive.NewObjectID() 58 | schoolID := primitive.NewObjectID() 59 | adminEmail, password := "testAdmin@test.com", "qwerty123" 60 | passwordHash, err := s.hasher.Hash(password) 61 | s.NoError(err) 62 | 63 | _, err = s.db.Collection("admins").InsertOne(context.Background(), domain.Admin{ 64 | ID: id, 65 | Email: adminEmail, 66 | Password: passwordHash, 67 | SchoolID: schoolID, 68 | }) 69 | s.NoError(err) 70 | 71 | jwt, err := s.getJwt(id) 72 | s.NoError(err) 73 | 74 | req, _ := http.NewRequest("GET", "/api/v1/admins/courses", nil) 75 | req.Header.Set("Content-type", "application/json") 76 | req.Header.Set("Authorization", "Bearer "+jwt) 77 | 78 | resp := httptest.NewRecorder() 79 | router.ServeHTTP(resp, req) 80 | 81 | r.Equal(http.StatusOK, resp.Result().StatusCode) 82 | } 83 | 84 | func (s *APITestSuite) TestAdminGetCourseByID() { 85 | router := gin.New() 86 | s.handler.Init(router.Group("/api")) 87 | r := s.Require() 88 | 89 | id := primitive.NewObjectID() 90 | schoolID := primitive.NewObjectID() 91 | adminEmail, password := "testAdmin@test.com", "qwerty123" 92 | passwordHash, err := s.hasher.Hash(password) 93 | s.NoError(err) 94 | 95 | _, err = s.db.Collection("admins").InsertOne(context.Background(), domain.Admin{ 96 | ID: id, 97 | Email: adminEmail, 98 | Password: passwordHash, 99 | SchoolID: schoolID, 100 | }) 101 | s.NoError(err) 102 | 103 | jwt, err := s.getJwt(id) 104 | s.NoError(err) 105 | 106 | req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/admins/courses/%s", school.Courses[0].ID.Hex()), nil) 107 | req.Header.Set("Content-type", "application/json") 108 | req.Header.Set("Authorization", "Bearer "+jwt) 109 | 110 | resp := httptest.NewRecorder() 111 | router.ServeHTTP(resp, req) 112 | 113 | r.Equal(http.StatusOK, resp.Result().StatusCode) 114 | } 115 | -------------------------------------------------------------------------------- /tests/courses_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/zhashkevych/creatly-backend/internal/domain" 12 | ) 13 | 14 | type courseResponse struct { 15 | ID string `json:"id"` 16 | Name string `json:"name"` 17 | Code string `json:"code"` 18 | Description string `json:"description"` 19 | Color string `json:"color"` 20 | ImageURL string `json:"imageUrl"` 21 | CreatedAt string `json:"createdAt"` 22 | UpdatedAt string `json:"updatedAt"` 23 | Published bool `json:"published"` 24 | } 25 | 26 | type offerResponse struct { 27 | ID string `json:"id"` 28 | Name string `json:"name"` 29 | Description string `json:"description"` 30 | SchoolID string `json:"schoolId"` 31 | PackageIDs []string `json:"packages"` 32 | Price struct { 33 | Value uint `json:"value"` 34 | Currency string `json:"currency"` 35 | } `json:"price"` 36 | } 37 | 38 | func (s *APITestSuite) TestGetAllCourses() { 39 | router := gin.New() 40 | s.handler.Init(router.Group("/api")) 41 | r := s.Require() 42 | 43 | req, _ := http.NewRequest("GET", "/api/v1/courses", nil) 44 | req.Header.Set("Content-type", "application/json") 45 | 46 | resp := httptest.NewRecorder() 47 | router.ServeHTTP(resp, req) 48 | 49 | r.Equal(http.StatusOK, resp.Result().StatusCode) 50 | 51 | var respCourses struct { 52 | Data []courseResponse `json:"data"` 53 | } 54 | 55 | respData, err := ioutil.ReadAll(resp.Body) 56 | s.NoError(err) 57 | 58 | err = json.Unmarshal(respData, &respCourses) 59 | s.NoError(err) 60 | 61 | r.Equal(1, len(respCourses.Data)) 62 | } 63 | 64 | func (s *APITestSuite) TestGetCourseById() { 65 | router := gin.New() 66 | s.handler.Init(router.Group("/api")) 67 | r := s.Require() 68 | 69 | req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/courses/%s", school.Courses[0].ID.Hex()), nil) 70 | req.Header.Set("Content-type", "application/json") 71 | 72 | resp := httptest.NewRecorder() 73 | router.ServeHTTP(resp, req) 74 | 75 | r.Equal(http.StatusOK, resp.Result().StatusCode) 76 | 77 | // Get Unpublished Course 78 | router = gin.New() 79 | s.handler.Init(router.Group("/api")) 80 | r = s.Require() 81 | 82 | req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/courses/%s", school.Courses[1].ID.Hex()), nil) 83 | req.Header.Set("Content-type", "application/json") 84 | 85 | resp = httptest.NewRecorder() 86 | router.ServeHTTP(resp, req) 87 | 88 | r.Equal(http.StatusBadRequest, resp.Result().StatusCode) 89 | } 90 | 91 | func (s *APITestSuite) TestGetCourseOffers() { 92 | router := gin.New() 93 | s.handler.Init(router.Group("/api")) 94 | r := s.Require() 95 | 96 | req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/courses/%s/offers", school.Courses[0].ID.Hex()), nil) 97 | req.Header.Set("Content-type", "application/json") 98 | 99 | resp := httptest.NewRecorder() 100 | router.ServeHTTP(resp, req) 101 | 102 | r.Equal(http.StatusOK, resp.Result().StatusCode) 103 | 104 | var respOffers struct { 105 | Data []offerResponse `json:"data"` 106 | } 107 | 108 | respData, err := ioutil.ReadAll(resp.Body) 109 | s.NoError(err) 110 | 111 | err = json.Unmarshal(respData, &respOffers) 112 | s.NoError(err) 113 | 114 | r.Equal(1, len(respOffers.Data)) 115 | r.Equal(offers[0].(domain.Offer).Name, respOffers.Data[0].Name) 116 | r.Equal(offers[0].(domain.Offer).Description, respOffers.Data[0].Description) 117 | r.Equal(offers[0].(domain.Offer).Price.Value, respOffers.Data[0].Price.Value) 118 | r.Equal(offers[0].(domain.Offer).Price.Currency, respOffers.Data[0].Price.Currency) 119 | } 120 | -------------------------------------------------------------------------------- /tests/data.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/zhashkevych/creatly-backend/internal/domain" 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | ) 9 | 10 | var ( 11 | school = domain.School{ 12 | ID: primitive.NewObjectID(), 13 | Courses: []domain.Course{ 14 | { 15 | ID: primitive.NewObjectID(), 16 | Name: "Course #1", 17 | Published: true, 18 | }, 19 | { 20 | ID: primitive.NewObjectID(), 21 | Name: "Course #2", // Unpublished course, shouldn't be available to student 22 | }, 23 | }, 24 | Settings: domain.Settings{ 25 | Domains: []string{"http://localhost:1337", "workshop.zhashkevych.com", ""}, 26 | Fondy: domain.Fondy{ 27 | Connected: true, 28 | }, 29 | }, 30 | } 31 | 32 | packages = []interface{}{ 33 | domain.Package{ 34 | ID: primitive.NewObjectID(), 35 | Name: "Package #1", 36 | CourseID: school.Courses[0].ID, 37 | }, 38 | } 39 | 40 | offers = []interface{}{ 41 | domain.Offer{ 42 | ID: primitive.NewObjectID(), 43 | Name: "Offer #1", 44 | Description: "Offer #1 Description", 45 | SchoolID: school.ID, 46 | PackageIDs: []primitive.ObjectID{packages[0].(domain.Package).ID}, 47 | Price: domain.Price{Value: 6900, Currency: "USD"}, 48 | }, 49 | } 50 | 51 | promocodes = []interface{}{ 52 | domain.PromoCode{ 53 | ID: primitive.NewObjectID(), 54 | Code: "TEST25", 55 | DiscountPercentage: 25, 56 | ExpiresAt: time.Now().Add(time.Hour), 57 | OfferIDs: []primitive.ObjectID{offers[0].(domain.Offer).ID}, 58 | SchoolID: school.ID, 59 | }, 60 | } 61 | 62 | modules = []interface{}{ 63 | domain.Module{ 64 | ID: primitive.NewObjectID(), 65 | Name: "Module #1", // Free Module, should be available to anyone 66 | CourseID: school.Courses[0].ID, 67 | Published: true, 68 | Lessons: []domain.Lesson{ 69 | { 70 | ID: primitive.NewObjectID(), 71 | Name: "Lesson #1", 72 | Published: true, 73 | }, 74 | }, 75 | }, 76 | domain.Module{ 77 | ID: primitive.NewObjectID(), 78 | Name: "Module #2", // Part of paid package, should be available only after purchase 79 | CourseID: school.Courses[0].ID, 80 | Published: true, 81 | PackageID: packages[0].(domain.Package).ID, 82 | Lessons: []domain.Lesson{ 83 | { 84 | ID: primitive.NewObjectID(), 85 | Name: "Lesson #1", 86 | Published: true, 87 | }, 88 | { 89 | ID: primitive.NewObjectID(), 90 | Name: "Lesson #2", 91 | Published: true, 92 | }, 93 | }, 94 | }, 95 | domain.Module{ 96 | ID: primitive.NewObjectID(), 97 | Name: "Module #1", // Part of unpublished course 98 | CourseID: school.Courses[1].ID, 99 | Published: true, 100 | PackageID: packages[0].(domain.Package).ID, 101 | Lessons: []domain.Lesson{ 102 | { 103 | ID: primitive.NewObjectID(), 104 | Name: "Lesson #1", 105 | Published: true, 106 | }, 107 | { 108 | ID: primitive.NewObjectID(), 109 | Name: "Lesson #2", 110 | Published: true, 111 | }, 112 | }, 113 | }, 114 | } 115 | ) 116 | -------------------------------------------------------------------------------- /tests/fixtures/callback_approved.json: -------------------------------------------------------------------------------- 1 | { 2 | "rrn": "429417347068", 3 | "masked_card": "444455XXXXXX6666", 4 | "sender_cell_phone": "", 5 | "response_status": "success", 6 | "sender_account": "", 7 | "fee": "", 8 | "rectoken_lifetime": "", 9 | "reversal_amount": "0", 10 | "settlement_amount": "0", 11 | "actual_amount": "3324000", 12 | "order_status": "approved", 13 | "response_description": "", 14 | "verification_status": "", 15 | "order_time": "21.07.2017 15:20:27", 16 | "actual_currency": "RUB", 17 | "order_id": "6008153f3dab4fb0573d1f96", 18 | "parent_order_id": "", 19 | "merchant_data": "", 20 | "tran_type": "purchase", 21 | "eci": "", 22 | "settlement_date": "", 23 | "payment_system": "card", 24 | "rectoken": "", 25 | "approval_code": "027440", 26 | "merchant_id": 1396424, 27 | "settlement_currency": "", 28 | "payment_id": 51247263, 29 | "product_id": "", 30 | "currency": "RUB", 31 | "card_bin": 444455, 32 | "response_code": "", 33 | "card_type": "VISA", 34 | "amount": "3324000", 35 | "sender_email": "test@taskombank.eu", 36 | "signature": "11cb466a3a0bcb0f5542338fea326c827c395b20" 37 | } -------------------------------------------------------------------------------- /tests/fixtures/callback_declined.json: -------------------------------------------------------------------------------- 1 | { 2 | "rrn": "429417347068", 3 | "masked_card": "444455XXXXXX6666", 4 | "sender_cell_phone": "", 5 | "response_status": "success", 6 | "sender_account": "", 7 | "fee": "", 8 | "rectoken_lifetime": "", 9 | "reversal_amount": "0", 10 | "settlement_amount": "0", 11 | "actual_amount": "3324000", 12 | "order_status": "declined", 13 | "response_description": "", 14 | "verification_status": "", 15 | "order_time": "21.07.2017 15:20:27", 16 | "actual_currency": "RUB", 17 | "order_id": "6008153f3dab4fb0573d1f96", 18 | "parent_order_id": "", 19 | "merchant_data": "", 20 | "tran_type": "purchase", 21 | "eci": "", 22 | "settlement_date": "", 23 | "payment_system": "card", 24 | "rectoken": "", 25 | "approval_code": "027440", 26 | "merchant_id": 1396424, 27 | "settlement_currency": "", 28 | "payment_id": 51247263, 29 | "product_id": "", 30 | "currency": "RUB", 31 | "card_bin": 444455, 32 | "response_code": "", 33 | "card_type": "VISA", 34 | "amount": "3324000", 35 | "sender_email": "test@taskombank.eu", 36 | "signature": "e745a506f1c695a9764bef031d3919844bba1403" 37 | } -------------------------------------------------------------------------------- /tests/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/suite" 10 | "github.com/zhashkevych/creatly-backend/internal/config" 11 | v1 "github.com/zhashkevych/creatly-backend/internal/delivery/http/v1" 12 | "github.com/zhashkevych/creatly-backend/internal/repository" 13 | "github.com/zhashkevych/creatly-backend/internal/service" 14 | "github.com/zhashkevych/creatly-backend/pkg/auth" 15 | "github.com/zhashkevych/creatly-backend/pkg/cache" 16 | "github.com/zhashkevych/creatly-backend/pkg/database/mongodb" 17 | emailmock "github.com/zhashkevych/creatly-backend/pkg/email/mock" 18 | "github.com/zhashkevych/creatly-backend/pkg/hash" 19 | "github.com/zhashkevych/creatly-backend/pkg/otp" 20 | "go.mongodb.org/mongo-driver/mongo" 21 | ) 22 | 23 | var dbURI, dbName string 24 | 25 | func init() { 26 | dbURI = os.Getenv("TEST_DB_URI") 27 | dbName = os.Getenv("TEST_DB_NAME") 28 | } 29 | 30 | type APITestSuite struct { 31 | suite.Suite 32 | 33 | db *mongo.Database 34 | handler *v1.Handler 35 | services *service.Services 36 | repos *repository.Repositories 37 | 38 | hasher hash.PasswordHasher 39 | tokenManager auth.TokenManager 40 | mocks *mocks 41 | } 42 | 43 | type mocks struct { 44 | emailSender *emailmock.EmailSender 45 | otpGenerator *otp.MockGenerator 46 | } 47 | 48 | func TestAPISuite(t *testing.T) { 49 | if testing.Short() { 50 | t.Skip() 51 | } 52 | 53 | suite.Run(t, new(APITestSuite)) 54 | } 55 | 56 | func (s *APITestSuite) SetupSuite() { 57 | if client, err := mongodb.NewClient(dbURI, "", ""); err != nil { 58 | s.FailNow("Failed to connect to mongo", err) 59 | } else { 60 | s.db = client.Database(dbName) 61 | } 62 | 63 | s.initMocks() 64 | s.initDeps() 65 | 66 | if err := s.populateDB(); err != nil { 67 | s.FailNow("Failed to populate DB", err) 68 | } 69 | } 70 | 71 | func (s *APITestSuite) TearDownSuite() { 72 | s.db.Client().Disconnect(context.Background()) //nolint:errcheck 73 | } 74 | 75 | func (s *APITestSuite) initDeps() { 76 | // Init domain deps 77 | repos := repository.NewRepositories(s.db) 78 | memCache := cache.NewMemoryCache() 79 | hasher := hash.NewSHA1Hasher("salt") 80 | 81 | tokenManager, err := auth.NewManager("signing_key") 82 | if err != nil { 83 | s.FailNow("Failed to initialize token manager", err) 84 | } 85 | 86 | services := service.NewServices(service.Deps{ 87 | 88 | Repos: repos, 89 | Cache: memCache, 90 | Hasher: hasher, 91 | TokenManager: tokenManager, 92 | EmailSender: s.mocks.emailSender, 93 | EmailConfig: config.EmailConfig{ 94 | Templates: config.EmailTemplates{ 95 | Verification: "../templates/verification_email.html", 96 | PurchaseSuccessful: "../templates/purchase_successful.html", 97 | }, 98 | Subjects: config.EmailSubjects{ 99 | Verification: "Спасибо за регистрацию, %s!", 100 | PurchaseSuccessful: "Покупка прошла успешно!", 101 | }, 102 | }, 103 | AccessTokenTTL: time.Minute * 15, 104 | RefreshTokenTTL: time.Minute * 15, 105 | CacheTTL: int64(time.Minute.Seconds()), 106 | OtpGenerator: s.mocks.otpGenerator, 107 | VerificationCodeLength: 8, 108 | }) 109 | 110 | s.repos = repos 111 | s.services = services 112 | s.handler = v1.NewHandler(services, tokenManager) 113 | s.hasher = hasher 114 | s.tokenManager = tokenManager 115 | } 116 | 117 | func (s *APITestSuite) initMocks() { 118 | s.mocks = &mocks{ 119 | emailSender: new(emailmock.EmailSender), 120 | otpGenerator: new(otp.MockGenerator), 121 | } 122 | } 123 | 124 | func TestMain(m *testing.M) { 125 | rc := m.Run() 126 | os.Exit(rc) 127 | } 128 | 129 | func (s *APITestSuite) populateDB() error { 130 | _, err := s.db.Collection("schools").InsertOne(context.Background(), school) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | _, err = s.db.Collection("packages").InsertMany(context.Background(), packages) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | _, err = s.db.Collection("offers").InsertMany(context.Background(), offers) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | _, err = s.db.Collection("modules").InsertMany(context.Background(), modules) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | _, err = s.db.Collection("promocodes").InsertMany(context.Background(), promocodes) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /tests/payment_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/zhashkevych/creatly-backend/internal/domain" 13 | "github.com/zhashkevych/creatly-backend/pkg/email" 14 | "github.com/zhashkevych/creatly-backend/pkg/payment/fondy" 15 | "go.mongodb.org/mongo-driver/bson/primitive" 16 | ) 17 | 18 | func (s *APITestSuite) TestFondyCallbackApproved() { 19 | router := gin.New() 20 | s.handler.Init(router.Group("/api")) 21 | r := s.Require() 22 | 23 | // populate DB data 24 | studentId := primitive.NewObjectID() 25 | studentEmail := "payment@test.com" 26 | studentName := "Test Payment" 27 | offerName := "Test Offer" 28 | _, err := s.db.Collection("students").InsertOne(context.Background(), domain.Student{ 29 | ID: studentId, 30 | Email: studentEmail, 31 | Name: studentName, 32 | SchoolID: school.ID, 33 | Verification: domain.Verification{Verified: true}, 34 | }) 35 | s.NoError(err) 36 | 37 | id, _ := primitive.ObjectIDFromHex("6008153f3dab4fb0573d1f96") 38 | _, err = s.db.Collection("orders").InsertOne(context.Background(), domain.Order{ 39 | ID: id, 40 | SchoolID: school.ID, 41 | Offer: domain.OrderOfferInfo{ID: offers[0].(domain.Offer).ID, Name: offerName}, 42 | Student: domain.StudentInfoShort{ID: studentId, Email: studentEmail, Name: studentName}, 43 | Status: "created", 44 | }) 45 | s.NoError(err) 46 | 47 | s.mocks.emailSender.On("Send", email.SendEmailInput{ 48 | To: studentEmail, 49 | Subject: "Покупка прошла успешно!", 50 | Body: fmt.Sprintf(`

%s, спасибо большое за покупку "%s"!

51 |
52 |

Надеюсь данный материал будет тебе полезен и интересен!

53 |

Если у тебя возникают вопросы или ты хочешь поделиться своим отзывом - пиши мне письмо на zhashkevychmaksim@gmail.com.

54 |

Мне крайне важен твой отзыв, чтобы улучшать материалы и делать курс максимально полезным!

55 | 56 |

57 | 58 |

С уважением, Максим

`, studentName, offerName), 59 | }).Return(nil) 60 | 61 | file, err := ioutil.ReadFile("./fixtures/callback_approved.json") 62 | s.NoError(err) 63 | 64 | req, _ := http.NewRequest("POST", "/api/v1/callback/fondy", bytes.NewBuffer(file)) 65 | req.Header.Set("Content-type", "application/json") 66 | req.Header.Set("User-Agent", fondy.UserAgent) 67 | 68 | resp := httptest.NewRecorder() 69 | router.ServeHTTP(resp, req) 70 | 71 | r.Equal(http.StatusOK, resp.Result().StatusCode) 72 | 73 | // Get Paid Lessons After Callback 74 | r = s.Require() 75 | 76 | jwt, err := s.getJwt(studentId) 77 | s.NoError(err) 78 | 79 | req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/students/modules/%s/content", modules[1].(domain.Module).ID.Hex()), nil) 80 | req.Header.Set("Content-type", "application/json") 81 | req.Header.Set("Authorization", "Bearer "+jwt) 82 | 83 | resp = httptest.NewRecorder() 84 | router.ServeHTTP(resp, req) 85 | 86 | r.Equal(http.StatusOK, resp.Result().StatusCode) 87 | } 88 | 89 | func (s *APITestSuite) TestFondyCallbackDeclined() { 90 | router := gin.New() 91 | s.handler.Init(router.Group("/api")) 92 | r := s.Require() 93 | 94 | // populate DB data 95 | studentId := primitive.NewObjectID() 96 | _, err := s.db.Collection("students").InsertOne(context.Background(), domain.Student{ 97 | ID: studentId, 98 | SchoolID: school.ID, 99 | Verification: domain.Verification{Verified: true}, 100 | }) 101 | s.NoError(err) 102 | 103 | id, _ := primitive.ObjectIDFromHex("6008153f3dab4fb0573d1f97") 104 | _, err = s.db.Collection("orders").InsertOne(context.Background(), domain.Order{ 105 | ID: id, 106 | SchoolID: school.ID, 107 | Offer: domain.OrderOfferInfo{ID: offers[0].(domain.Offer).ID}, 108 | Student: domain.StudentInfoShort{ID: studentId}, 109 | Status: "created", 110 | }) 111 | s.NoError(err) 112 | 113 | file, err := ioutil.ReadFile("./fixtures/callback_declined.json") 114 | s.NoError(err) 115 | 116 | req, _ := http.NewRequest("POST", "/api/v1/callback/fondy", bytes.NewBuffer(file)) 117 | req.Header.Set("Content-type", "application/json") 118 | req.Header.Set("User-Agent", fondy.UserAgent) 119 | 120 | resp := httptest.NewRecorder() 121 | router.ServeHTTP(resp, req) 122 | 123 | r.Equal(http.StatusOK, resp.Result().StatusCode) 124 | 125 | // Get Paid Lessons After Callback 126 | r = s.Require() 127 | 128 | jwt, err := s.getJwt(studentId) 129 | s.NoError(err) 130 | 131 | req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/students/modules/%s/content", modules[1].(domain.Module).ID.Hex()), nil) 132 | req.Header.Set("Content-type", "application/json") 133 | req.Header.Set("Authorization", "Bearer "+jwt) 134 | 135 | resp = httptest.NewRecorder() 136 | router.ServeHTTP(resp, req) 137 | 138 | r.Equal(http.StatusForbidden, resp.Result().StatusCode) 139 | } 140 | -------------------------------------------------------------------------------- /tests/promocodes_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/zhashkevych/creatly-backend/internal/domain" 10 | ) 11 | 12 | func (s *APITestSuite) TestGetPromoCode() { 13 | router := gin.New() 14 | s.handler.Init(router.Group("/api")) 15 | r := s.Require() 16 | 17 | req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/promocodes/%s", promocodes[0].(domain.PromoCode).Code), nil) 18 | req.Header.Set("Content-type", "application/json") 19 | 20 | resp := httptest.NewRecorder() 21 | router.ServeHTTP(resp, req) 22 | 23 | r.Equal(http.StatusOK, resp.Result().StatusCode) 24 | } 25 | 26 | func (s *APITestSuite) TestGetPromoCodeInvalid() { 27 | router := gin.New() 28 | s.handler.Init(router.Group("/api")) 29 | r := s.Require() 30 | 31 | req, _ := http.NewRequest("GET", "/api/v1/promocodes/CODE123", nil) 32 | req.Header.Set("Content-type", "application/json") 33 | 34 | resp := httptest.NewRecorder() 35 | router.ServeHTTP(resp, req) 36 | 37 | r.Equal(http.StatusBadRequest, resp.Result().StatusCode) 38 | } 39 | --------------------------------------------------------------------------------