This commit is contained in:
Alex Shevchuk
2025-08-18 17:12:04 +03:00
commit d84487d238
157 changed files with 160686 additions and 0 deletions

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM golang:1.24-alpine AS builder
ARG BUILD_LDFLAGS
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download -x
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="${BUILD_LDFLAGS}" -trimpath -o main ./cmd/main.go
FROM alpine:3.18 AS certs
RUN wget -q "https://storage.yandexcloud.net/cloud-certs/CA.pem" -O /YandexInternalRootCA.crt \
&& chmod 0655 /YandexInternalRootCA.crt
FROM alpine:3.18
WORKDIR /
ARG CONFIG_FILE_PATH
RUN apk add --no-cache ca-certificates tzdata
COPY --from=certs /YandexInternalRootCA.crt /root/.redis/YandexInternalRootCA.crt
COPY --from=certs /YandexInternalRootCA.crt /root/.postgres/YandexInternalRootCA.crt
COPY ${CONFIG_FILE_PATH} /config.yaml
COPY --from=builder /app/main /main
RUN adduser -D -u 10001 appuser && chown appuser /main
USER appuser
ENTRYPOINT ["/main"]
CMD ["--config_path", "/config.yaml"]

82
Makefile Normal file
View File

@@ -0,0 +1,82 @@
BIN_DIR := $(CURDIR)/bin
APP_NAME ?= molva-app-backend
VERSION ?= $(shell git describe --tags 2>/dev/null || echo "v0.0.0")
BUILD_DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null)
LDFLAGS := "-s -w -X main.version=$(VERSION) -X main.commit=$(GIT_COMMIT) -X main.date=$(BUILD_DATE)"
GOLANGCI_LINT_VERSION := v1.62.2
ENV := "local"
UNAME_S := $(shell uname -s)
CONFIG_PATH := .build/config/
ifeq ($(OS),Windows_NT)
CONFIG_PATH := $(subst /,\,$(CONFIG_PATH))
endif
.PHONY: all
all: lint build
## -- Dependency Management --
.PHONY: install-tools
install-tools: $(BIN_DIR)/golangci-lint
.PHONY: $(BIN_DIR)/golangci-lint
$(BIN_DIR)/golangci-lint:
@mkdir -p $(@D)
@echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..."
@GOBIN=$(BIN_DIR) go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
## -- Linting --
.PHONY: lint
lint: $(BIN_DIR)/golangci-lint
@echo "Running linter..."
@$(BIN_DIR)/golangci-lint run --config .golangci-lint.yaml --fix=false --color=always
## -- Run --
## To change env use: make run CONFIG_PATH={config file dir path} ENV={env}
.PHONY: run
run:
@echo "Starting $(APP_NAME)..."
@echo CGO_ENABLED=0 go run -ldflags=$(LDFLAGS) ./cmd/main.go --config_path=$(CONFIG_PATH) --env=$(ENV)
@CGO_ENABLED=0 go run -ldflags=$(LDFLAGS) ./cmd/main.go --config_path=$(CONFIG_PATH) --env=$(ENV)
## -- Building --
.PHONY: build
build: build-linux build-darwin build-windows
# Build for Linux
.PHONY: build-linux
build-linux:
@echo "Building for Linux..."
@GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags=$(LDFLAGS) -o $(BIN_DIR)/$(APP_NAME)_linux_amd64 ./cmd/main.go
@GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags=$(LDFLAGS) -o $(BIN_DIR)/$(APP_NAME)_linux_arm64 ./cmd/main.go
# Build for macOS (Darwin)
.PHONY: build-darwin
build-darwin:
@echo "Building for macOS..."
@GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags=$(LDFLAGS) -o $(BIN_DIR)/$(APP_NAME)_darwin_amd64 ./cmd/main.go
@GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags=$(LDFLAGS) -o $(BIN_DIR)/$(APP_NAME)_darwin_arm64 ./cmd/main.go
# Build for Windows
.PHONY: build-windows
build-windows:
@echo "Building for Windows..."
@GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags=$(LDFLAGS) -o $(BIN_DIR)/$(APP_NAME)_windows_amd64.exe ./cmd/main.go
@GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags=$(LDFLAGS) -o $(BIN_DIR)/$(APP_NAME)_windows_arm64.exe ./cmd/main.go
## -- Docker --
.PHONY: docker-build
docker-build:
@echo "Building Docker image..."
@docker build \
--build-arg BUILD_LDFLAGS=$(LDFLAGS) \
--build-arg CONFIG_FILE_PATH="$(CONFIG_PATH)$(ENV).yaml" \
-t $(APP_NAME):$(VERSION) \
.
## -- Cleanup --
.PHONY: clean
clean:
@echo "Cleaning..."
@rm -rf $(BIN_DIR)

169
README.md Normal file
View File

@@ -0,0 +1,169 @@
# Molva Backend
Backend ัะตั€ะฒะธั ะดะปั ะฟะปะฐั‚ั„ะพั€ะผั‹ Molva, ะฝะฐะฟะธัะฐะฝะฝั‹ะน ะฝะฐ Go.
## ะขั€ะตะฑะพะฒะฐะฝะธั
- Go 1.24+
- Docker (ะพะฟั†ะธะพะฝะฐะปัŒะฝะพ)
- Git
## ะฃัั‚ะฐะฝะพะฒะบะฐ ะธ ะฝะฐัั‚ั€ะพะนะบะฐ
### 1. ะšะปะพะฝะธั€ะพะฒะฐะฝะธะต ั€ะตะฟะพะทะธั‚ะพั€ะธั
```bash
git clone <repository-url>
cd molva-backend
```
### 2. ะฃัั‚ะฐะฝะพะฒะบะฐ ะทะฐะฒะธัะธะผะพัั‚ะตะน
```bash
go mod download
```
### 3. ะฃัั‚ะฐะฝะพะฒะบะฐ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ ั€ะฐะทั€ะฐะฑะพั‚ะบะธ
```bash
make install-tools
```
## ะ—ะฐะฟัƒัะบ ะฟั€ะพะตะบั‚ะฐ
### ะ›ะพะบะฐะปัŒะฝะฐั ั€ะฐะทั€ะฐะฑะพั‚ะบะฐ
ะ”ะปั ะทะฐะฟัƒัะบะฐ ะฒ ั€ะตะถะธะผะต ั€ะฐะทั€ะฐะฑะพั‚ะบะธ:
```bash
make run
```
ะŸะพ ัƒะผะพะปั‡ะฐะฝะธัŽ ะฟั€ะพะตะบั‚ ะทะฐะฟัƒัะบะฐะตั‚ัั ั ะบะพะฝั„ะธะณัƒั€ะฐั†ะธะตะน:
- **ะšะพะฝั„ะธะณัƒั€ะฐั†ะธั**: `.build/config/`
- **ะžะบั€ัƒะถะตะฝะธะต**: `local`
### ะ˜ะทะผะตะฝะตะฝะธะต ะฟะฐั€ะฐะผะตั‚ั€ะพะฒ ะทะฐะฟัƒัะบะฐ
ะ’ั‹ ะผะพะถะตั‚ะต ะธะทะผะตะฝะธั‚ัŒ ะฟะฐั€ะฐะผะตั‚ั€ั‹ ะทะฐะฟัƒัะบะฐ:
```bash
# ะ˜ะทะผะตะฝะตะฝะธะต ะฟัƒั‚ะธ ะบ ะบะพะฝั„ะธะณัƒั€ะฐั†ะธะธ
make run CONFIG_PATH=...
# ะ˜ะทะผะตะฝะตะฝะธะต ะพะบั€ัƒะถะตะฝะธั
make run ENV=...
# ะ˜ะทะผะตะฝะตะฝะธะต ะพะฑะพะธั… ะฟะฐั€ะฐะผะตั‚ั€ะพะฒ
make run CONFIG_PATH=... ENV=...
```
### ะกะฑะพั€ะบะฐ ะฟั€ะพะตะบั‚ะฐ
#### ะกะฑะพั€ะบะฐ ะดะปั ะฒัะตั… ะฟะปะฐั‚ั„ะพั€ะผ
```bash
make build
```
ะญั‚ะฐ ะบะพะผะฐะฝะดะฐ ัะพะทะดะฐัั‚ ะธัะฟะพะปะฝัะตะผั‹ะต ั„ะฐะนะปั‹ ะดะปั:
- Linux (amd64, arm64)
- macOS (amd64, arm64)
- Windows (amd64, arm64)
ะคะฐะนะปั‹ ะฑัƒะดัƒั‚ ัะพั…ั€ะฐะฝะตะฝั‹ ะฒ ะดะธั€ะตะบั‚ะพั€ะธะธ `bin/` ั ะธะผะตะฝะฐะผะธ:
- `molva-app-backend_linux_amd64`
- `molva-app-backend_linux_arm64`
- `molva-app-backend_darwin_amd64`
- `molva-app-backend_darwin_arm64`
- `molva-app-backend_windows_amd64.exe`
- `molva-app-backend_windows_arm64.exe`
#### ะกะฑะพั€ะบะฐ ะดะปั ะบะพะฝะบั€ะตั‚ะฝะพะน ะฟะปะฐั‚ั„ะพั€ะผั‹
```bash
# ะขะพะปัŒะบะพ ะดะปั Linux
make build-linux
# ะขะพะปัŒะบะพ ะดะปั macOS
make build-darwin
# ะขะพะปัŒะบะพ ะดะปั Windows
make build-windows
```
### Docker
ะ”ะปั ัะฑะพั€ะบะธ Docker ะพะฑั€ะฐะทะฐ:
```bash
make docker-build
```
ะžะฑั€ะฐะท ะฑัƒะดะตั‚ ัะพะทะดะฐะฝ ั ั‚ะตะณะพะผ `molva-app-backend:$(VERSION)`, ะณะดะต `VERSION` - ัั‚ะพ git tag ะธะปะธ "v0.0.0" ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ.
## ะ ะฐะทั€ะฐะฑะพั‚ะบะฐ
### ะŸั€ะพะฒะตั€ะบะฐ ะบะพะดะฐ
ะŸะตั€ะตะด ะบะพะผะผะธั‚ะพะผ ั€ะตะบะพะผะตะฝะดัƒะตั‚ัั ะทะฐะฟัƒัั‚ะธั‚ัŒ ะปะธะฝั‚ะตั€:
```bash
make lint
```
### ะžั‡ะธัั‚ะบะฐ
ะ”ะปั ัƒะดะฐะปะตะฝะธั ัะบะพะผะฟะธะปะธั€ะพะฒะฐะฝะฝั‹ั… ั„ะฐะนะปะพะฒ:
```bash
make clean
```
## ะกั‚ั€ัƒะบั‚ัƒั€ะฐ ะฟั€ะพะตะบั‚ะฐ
```
molva-backend/
โ”œโ”€โ”€ bin/ # ะกะบะพะผะฟะธะปะธั€ะพะฒะฐะฝะฝั‹ะต ั„ะฐะนะปั‹
โ”œโ”€โ”€ cmd/
โ”‚ โ””โ”€โ”€ main.go # ะขะพั‡ะบะฐ ะฒั…ะพะดะฐ ะฟั€ะธะปะพะถะตะฝะธั
โ”œโ”€โ”€ internal/ # ะ’ะฝัƒั‚ั€ะตะฝะฝะธะต ะฟะฐะบะตั‚ั‹
โ”‚ โ”œโ”€โ”€ auth/ # ะัƒั‚ะตะฝั‚ะธั„ะธะบะฐั†ะธั
โ”‚ โ”œโ”€โ”€ broker/ # Message broker
โ”‚ โ”œโ”€โ”€ cache/ # ะšััˆะธั€ะพะฒะฐะฝะธะต
โ”‚ โ”œโ”€โ”€ config/ # ะšะพะฝั„ะธะณัƒั€ะฐั†ะธั
โ”‚ โ”œโ”€โ”€ database/ # ะ ะฐะฑะพั‚ะฐ ั ะ‘ะ”
โ”‚ โ”œโ”€โ”€ feed/ # ะ›ะตะฝั‚ะฐ
โ”‚ โ”œโ”€โ”€ file_manager/ # ะฃะฟั€ะฐะฒะปะตะฝะธะต ั„ะฐะนะปะฐะผะธ
โ”‚ โ”œโ”€โ”€ form_generator/ # ะ“ะตะฝะตั€ะฐั‚ะพั€ ั„ะพั€ะผ
โ”‚ โ”œโ”€โ”€ http/ # HTTP handlers
โ”‚ โ”œโ”€โ”€ integration/ # ะ˜ะฝั‚ะตะณั€ะฐั†ะธะธ
โ”‚ โ”œโ”€โ”€ logger/ # ะ›ะพะณะธั€ะพะฒะฐะฝะธะต
โ”‚ โ”œโ”€โ”€ notifications/ # ะฃะฒะตะดะพะผะปะตะฝะธั
โ”‚ โ”œโ”€โ”€ object_storage/ # ะžะฑัŠะตะบั‚ะฝะพะต ั…ั€ะฐะฝะธะปะธั‰ะต
โ”‚ โ”œโ”€โ”€ request_model/ # ะœะพะดะตะปะธ ะทะฐะฟั€ะพัะพะฒ
โ”‚ โ”œโ”€โ”€ services/ # ะกะตั€ะฒะธัั‹
โ”‚ โ”œโ”€โ”€ types/ # ะขะธะฟั‹ ะดะฐะฝะฝั‹ั…
โ”‚ โ””โ”€โ”€ url_shortener/ # ะกะพะบั€ะฐั‰ะตะฝะธะต URL
โ”œโ”€โ”€ Dockerfile
โ”œโ”€โ”€ go.mod
โ”œโ”€โ”€ Makefile
โ””โ”€โ”€ README.md
```
## ะšะพะฝั„ะธะณัƒั€ะฐั†ะธั
ะŸั€ะพะตะบั‚ ะธัะฟะพะปัŒะทัƒะตั‚ ะบะพะฝั„ะธะณัƒั€ะฐั†ะธะพะฝะฝั‹ะต ั„ะฐะนะปั‹ ะฒ ั„ะพั€ะผะฐั‚ะต YAML. ะŸะพ ัƒะผะพะปั‡ะฐะฝะธัŽ ะพะฝะธ ะฝะฐั…ะพะดัั‚ัั ะฒ ะดะธั€ะตะบั‚ะพั€ะธะธ `.build/config/`.
ะกั‚ั€ัƒะบั‚ัƒั€ะฐ ะบะพะฝั„ะธะณัƒั€ะฐั†ะธะธ:
- `local.yaml` - ะดะปั ะปะพะบะฐะปัŒะฝะพะน ั€ะฐะทั€ะฐะฑะพั‚ะบะธ
- `staging.yaml` - ะดะปั dev ะพะบั€ัƒะถะตะฝะธั
- `production.yaml` - ะดะปั production ะพะบั€ัƒะถะตะฝะธั
## ะŸะตั€ะตะผะตะฝะฝั‹ะต ัะฑะพั€ะบะธ
ะŸั€ะธะปะพะถะตะฝะธะต ะฒะบะปัŽั‡ะฐะตั‚ ัะปะตะดัƒัŽั‰ะธะต ะฟะตั€ะตะผะตะฝะฝั‹ะต ัะฑะพั€ะบะธ:
- `version` - ะฒะตั€ัะธั ะฟั€ะธะปะพะถะตะฝะธั (git tag)
- `commit` - ั…ะตัˆ ะบะพะผะผะธั‚ะฐ
- `date` - ะดะฐั‚ะฐ ัะฑะพั€ะบะธ

5
build_push.sh Executable file
View File

@@ -0,0 +1,5 @@
echo "714dadb9-e8ad-468c-a77f-e98d86cd7393" | docker login -u "molvaapp" --password-stdin
IMAGE_NAME="molvaapp/api_gateway"
TAG="latest"
docker build -t $IMAGE_NAME:$TAG .
docker push $IMAGE_NAME:$TAG

126
cmd/main.go Normal file
View File

@@ -0,0 +1,126 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/logger"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/constants"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/broker"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/config"
httprouter "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/http"
)
var (
version string
commit string
date string
)
func main() {
cfg := config.MustLoad(version, commit, date)
log, err := logger.New(cfg.Env)
if err != nil {
panic(err)
}
log.Info("Starting App", slog.String("version", version), slog.String("commit", commit), slog.String("build_date", date))
brok := setupBroker(log, &cfg.Broker)
defer func(brok *broker.RabbitMQBroker) {
if err := brok.Disconnect(); err != nil {
log.Error("Error disconnecting from broker")
}
}(brok)
server, err := setupServer(log, cfg)
if err != nil {
panic(fmt.Sprintf("can not start http server: %v", err))
}
serverErrors := make(chan error, 1)
go func() {
log.Info("starting server", slog.Int("port", cfg.Port))
serverErrors <- server.ListenAndServe()
}()
shutdownSignal := make(chan os.Signal, 1)
signal.Notify(shutdownSignal, os.Interrupt, syscall.SIGTERM)
select {
case err := <-serverErrors:
log.Error("Error starting server: %v\n", slog.Any("err", err))
case sig := <-shutdownSignal:
log.Info("received signal. shutting down server", slog.String("signal", sig.String()))
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
log.Info("shutting down server...")
if err := server.Shutdown(ctx); err != nil {
log.Error(fmt.Sprintf("Error shutting down server: %v\n", err))
}
log.Info("server gracefully stopped")
}
func setupServer(logger *slog.Logger, cfg *config.Config) (*http.Server, error) {
router, err := httprouter.New(logger, cfg)
if err != nil {
return nil, err
}
httprouter.SetupRouter(router, cfg.Build)
return &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Handler: router.Mux,
ReadHeaderTimeout: 15 * time.Second,
}, nil
}
func doWithRetry(function func() error, serviceName string, logger *slog.Logger) {
if err := function(); err != nil {
logger.Error(fmt.Sprintf("Error connecting to %s: %v", serviceName, err),
slog.String("error", err.Error()))
go func() {
ticker := time.NewTicker(constants.DefaultRetryTimeout)
defer ticker.Stop()
for range ticker.C {
if err := function(); err != nil {
logger.Error(fmt.Sprintf("Error connecting to %s: %v", serviceName, err),
slog.String("error", err.Error()))
} else {
logger.Info(fmt.Sprintf("Successfully connected to %s service", serviceName))
break
}
}
}()
} else {
logger.Info(fmt.Sprintf("Successfully connected to %s service", serviceName))
}
}
func setupBroker(logger *slog.Logger, cfg *config.Broker) *broker.RabbitMQBroker {
brok := broker.NewRabbitMQBroker(cfg)
doWithRetry(func() error {
return brok.Connect()
}, "Broker", logger)
return brok
}

View File

@@ -0,0 +1,53 @@
version: "3.9"
services:
molva-api-gateway:
image: molvaapp/api_gateway:${VERSION:-latest}
container_name: molva-api-gateway
restart: unless-stopped
ports:
- "8001:8000"
volumes:
- /opt/molva:/opt/molva
- /opt/molva/object_storage/credentials:/root/.aws/credentials:ro
- /opt/molva/object_storage/config:/root/.aws/config:ro
networks:
- molva
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/healthcheck"]
deploy:
mode: replicated
replicas: 3
restart_policy:
condition: on-failure
delay: 20s
window: 120s
update_config:
parallelism: 1
failure_action: rollback
monitor: 60s
max_failure_ratio: 0.3
order: start-first
rollback_config:
parallelism: 0
failure_action: continue
order: start-first
placement:
constraints:
- node.labels.env == stg
- node.labels.app == true
preferences:
- spread: node.labels.TZ
molva-notifications:
image: molvaapp/notification:latest
container_name: molva-notifications
restart: unless-stopped
volumes:
- /opt/molva:/opt/molva
networks:
- molva
networks:
molva:
external: true

62
go.mod Normal file
View File

@@ -0,0 +1,62 @@
module git-molva.ru/Molva/molva-backend/services/api_gateway
go 1.24
require (
github.com/AlexOreL-272/ProtoMolva v1.32.5
github.com/Masterminds/squirrel v1.5.4
github.com/Nerzal/gocloak/v13 v13.9.0
github.com/aws/aws-sdk-go-v2 v1.36.3
github.com/aws/aws-sdk-go-v2/config v1.29.14
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.4
github.com/aws/smithy-go v1.22.3
github.com/go-redis/redis/v7 v7.4.1
github.com/golang-jwt/jwt/v5 v5.2.3
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/ilyakaznacheev/cleanenv v1.5.0
github.com/jackc/pgx/v5 v5.7.5
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.10.9
github.com/rabbitmq/amqp091-go v1.10.0
google.golang.org/protobuf v1.36.6
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/grpc v1.71.0 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)

186
go.sum Normal file
View File

@@ -0,0 +1,186 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlexOreL-272/ProtoMolva v1.32.5 h1:mQybFqLmwlW7DpSppiRsID11m6t8GGOOkiuzWtsjyT0=
github.com/AlexOreL-272/ProtoMolva v1.32.5/go.mod h1:0lY7t+z3fhKs2ZxsdVvAePxAC4UdIRQT7LOejnJmb00=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/Nerzal/gocloak/v13 v13.9.0 h1:YWsJsdM5b0yhM2Ba3MLydiOlujkBry4TtdzfIzSVZhw=
github.com/Nerzal/gocloak/v13 v13.9.0/go.mod h1:YYuDcXZ7K2zKECyVP7pPqjKxx2AzYSpKDj8d6GuyM10=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM=
github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 h1:BCG7DCXEXpNCcpwCxg1oi9pkJWH2+eZzTn9MY56MbVw=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.4 h1:4yxno6bNHkekkfqG/a1nz/gC2gBwhJSojV1+oTE7K+4=
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.4/go.mod h1:qbn305Je/IofWBJ4bJz/Q7pDEtnnoInw/dGt71v6rHE=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI=
github.com/go-redis/redis/v7 v7.4.1/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=

27
internal/auth/client.go Normal file
View File

@@ -0,0 +1,27 @@
package auth
import (
"context"
)
type Manager interface {
RegisterUser(ctx context.Context, request RegisterUserRequest) (*RegisterUserResponse, error)
LoginUser(ctx context.Context, request LoginUserRequest) (*LoginUserResponse, error)
LogoutUser(ctx context.Context, request LogoutUserRequest) error
GetNewAccessToken(ctx context.Context, request GetNewAccessTokenRequest) (*GetNewAccessTokenResponse, error)
GetUserEmailVerificationToken(ctx context.Context, uid string) (*GetUserTokenResponse, error)
CheckTokenIsValid(ctx context.Context, token string) (bool, error)
GetPermissionsByUsersId(ctx context.Context, id string) (*GetPermissionsByUsersIdResponse, error)
CheckPermissions(ctx context.Context, required CheckPermissionsRequest) (bool, error)
GetUserInfo(ctx context.Context, id string) (*UserInfo, error)
VerifyEmail(ctx context.Context, userID string, token string) error
GetEmailVerificationStatus(ctx context.Context, userID string) (*GetEmailVerificationResponse, error)
ResetPassword(ctx context.Context, request ResetPasswordRequest) error
}
type Config struct {
AuthServerAddr string
Realm string
ClientId string
ClientSecret string
}

104
internal/auth/domain.go Normal file
View File

@@ -0,0 +1,104 @@
package auth
type (
User struct {
Email string
Password string
FirstName string
SecondName string
Patronymic *string
Number string
Permissions map[string]string
}
RegisterUserRequest struct {
User User
UserType int32
}
RegisterUserResponse struct {
UserId string
}
)
type (
LoginUserRequest struct {
Email string
Password string
}
LoginUserResponse struct {
UserId string `json:"user_id"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
)
type (
GetUserTokenResponse struct {
AccessToken string `json:"access_token"`
}
)
type (
LogoutUserRequest struct {
RefreshToken string
}
)
type (
GetNewAccessTokenRequest struct {
RefreshToken string
}
GetNewAccessTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
)
type (
UserInfoFromToken struct {
UserId string
}
)
type (
GetPermissionsByUsersIdResponse struct {
Balance string
Company string
Employees string
Profile string
Submissions string
Vacancies string
}
)
type (
CheckPermissionsRequest struct {
UserId string
RequiredPermission string
RequiredPermissionLevel string
}
)
type (
ResetPasswordRequest struct {
Email string
NewPassword string
}
)
type (
UserInfo struct {
Email string
FirstName string
SecondName string
Patronymic *string
Permissions map[string]string
UserType int32
}
)
type GetEmailVerificationResponse struct {
EmailVerified bool `json:"email_verified"`
}

View File

@@ -0,0 +1,390 @@
package keycloak
import (
"context"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/auth"
"github.com/Nerzal/gocloak/v13"
)
func (k *Client) RegisterUser(ctx context.Context, request auth.RegisterUserRequest) (*auth.RegisterUserResponse, error) {
attrs := make(map[string][]string)
if request.User.Patronymic != nil {
attrs[PatronymicAttr] = []string{*request.User.Patronymic}
}
for key, value := range request.User.Permissions {
attrs[key] = []string{value}
}
for _, perm := range permissionsSlice {
if _, ok := attrs[perm]; !ok {
return nil, errors.Join(ErrBadRequest, ErrMissingPermission)
}
}
client, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
userGroup, err := k.getUserGroupByType(request.UserType)
if err != nil {
return nil, err
}
emailVerified := false
if k.env != "production" {
emailVerified = true
}
userId, err := k.client.CreateUser(
ctx,
client.AccessToken,
k.realm,
gocloak.User{
Email: gocloak.StringP(request.User.Email),
EmailVerified: &emailVerified,
FirstName: gocloak.StringP(request.User.FirstName),
LastName: gocloak.StringP(request.User.SecondName),
Groups: &[]string{userGroup},
Enabled: gocloak.BoolP(true),
Attributes: &attrs,
})
if err != nil {
return nil, k.handleError(err)
}
if err = k.client.SetPassword(
ctx, client.AccessToken,
userId, k.realm,
request.User.Password, false,
); err != nil {
return nil, k.handleError(err)
}
return &auth.RegisterUserResponse{
UserId: userId,
}, nil
}
func (k *Client) getUserGroupByType(userType int32) (string, error) {
switch userType {
case UserAgentType:
return UserGroupAgents, nil
case UserDistributorType:
return UserGroupDistributor, nil
default:
return "", ErrUnknownUserType
}
}
func (k *Client) LoginUser(ctx context.Context, request auth.LoginUserRequest) (*auth.LoginUserResponse, error) {
token, err := k.client.Login(ctx, k.clientId, k.clientSecret, k.realm, request.Email, request.Password)
if err != nil {
return nil, k.handleError(err)
}
userInfo, err := k.jwtManager.GetUserInfoFromToken(token.AccessToken)
if err != nil {
return nil, k.handleError(err)
}
return &auth.LoginUserResponse{
UserId: userInfo.UserId,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}, nil
}
func (k *Client) GetNewAccessToken(ctx context.Context, request auth.GetNewAccessTokenRequest) (*auth.GetNewAccessTokenResponse, error) {
token, err := k.client.RefreshToken(ctx, request.RefreshToken, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
return &auth.GetNewAccessTokenResponse{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}, nil
}
func (k *Client) CheckTokenIsValid(ctx context.Context, token string) (bool, error) {
resp, err := k.client.RetrospectToken(ctx, token, k.clientId, k.clientSecret, k.realm)
if err != nil {
return false, k.handleError(err)
}
if resp.Active == nil {
return false, nil
}
return *resp.Active, nil
}
func (k *Client) LogoutUser(ctx context.Context, request auth.LogoutUserRequest) error {
if err := k.client.Logout(ctx, k.clientId, k.clientSecret, k.realm, request.RefreshToken); err != nil {
return k.handleError(err)
}
if err := k.client.RevokeToken(ctx, k.realm, k.clientId, k.clientSecret, request.RefreshToken); err != nil {
return k.handleError(err)
}
return nil
}
func (k *Client) GetUserInfo(ctx context.Context, id string) (*auth.UserInfo, error) {
client, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
user, err := k.client.GetUserByID(ctx, client.AccessToken, k.realm, id)
if err != nil {
return nil, k.handleError(err)
}
if user.Attributes == nil {
return nil, ErrNoAttributes
}
groups, err := k.client.GetUserGroups(ctx, client.AccessToken, k.realm, id, gocloak.GetGroupsParams{})
if err != nil {
return nil, k.handleError(err)
}
if len(groups) == 0 {
return nil, ErrUnknownUserType
}
if groups[0].Name == nil {
return nil, ErrUnknownUserType
}
userType, err := k.getUserTypeByUserGroup(*groups[0].Name)
if err != nil {
return nil, err
}
return &auth.UserInfo{
Email: *user.Email,
FirstName: *user.FirstName,
SecondName: *user.LastName,
Patronymic: k.getUserPatronymicFromAttributes(*user.Attributes),
Permissions: k.getUserPermissionsFromAttributes(*user.Attributes),
UserType: userType,
}, nil
}
func (k *Client) getUserPatronymicFromAttributes(attr attributes) *string {
item, ok := attr[PatronymicAttr]
if !ok || len(item) == 0 {
return nil
}
return &item[0]
}
func (k *Client) getUserPermissionsFromAttributes(attr attributes) permissions {
resp := make(map[string]string)
for _, permission := range permissionsSlice {
if item, ok := attr[permission]; ok {
if len(item) != 0 {
resp[permission] = item[0]
} else {
resp[permission] = "" // todo: ะฟะพะดัƒะผะฐั‚ัŒ ะฝะฐะด ั‚ะตะผ ะบะฐะบ ะฟั€ะฐะฒะธะปัŒะฝะพ ะดะตะปะฐั‚ัŒ ะฒ ั‚ะฐะบะธั… ัะธั‚ัƒะฐั†ะธัั…
}
} else {
resp[permission] = "" // todo: ะฟะพะดัƒะผะฐั‚ัŒ ะฝะฐะด ั‚ะตะผ ะบะฐะบ ะฟั€ะฐะฒะธะปัŒะฝะพ ะดะตะปะฐั‚ัŒ ะฒ ั‚ะฐะบะธั… ัะธั‚ัƒะฐั†ะธัั…
}
}
return resp
}
func (k *Client) getUserTypeByUserGroup(attr string) (int32, error) {
switch attr {
case UserGroupAgents:
return UserAgentType, nil
case UserGroupDistributor:
return UserDistributorType, nil
default:
return -1, ErrUnknownUserType
}
}
func (k *Client) GetPermissionsByUsersId(ctx context.Context, id string) (*auth.GetPermissionsByUsersIdResponse, error) {
userInfo, err := k.GetUserInfo(ctx, id)
if err != nil {
return nil, err
}
return &auth.GetPermissionsByUsersIdResponse{
Balance: userInfo.Permissions[PermissionBalance],
Company: userInfo.Permissions[PermissionCompany],
Employees: userInfo.Permissions[PermissionEmployees],
Profile: userInfo.Permissions[PermissionProfile],
Submissions: userInfo.Permissions[PermissionSubmissions],
Vacancies: userInfo.Permissions[PermissionVacancies],
}, nil
}
func (k *Client) CheckPermissions(ctx context.Context, request auth.CheckPermissionsRequest) (bool, error) {
userInfo, err := k.GetUserInfo(ctx, request.UserId)
if err != nil {
return false, err
}
if userInfo.Permissions == nil {
return false, ErrInternal
}
if val, ok := userInfo.Permissions[request.RequiredPermission]; ok {
return k.getPermissionLevel(val) >= k.getPermissionLevel(request.RequiredPermissionLevel), nil
}
return false, ErrInternal
}
func (k *Client) getPermissionLevel(permission string) int8 {
switch permission {
case PermissionLevelCanEdit:
return 2
case PermissionLevelCanView:
return 1
case PermissionLevelNoPermission:
return 0
default:
return -1
}
}
func (k *Client) VerifyEmail(ctx context.Context, userID string, token string) error {
if err := k.jwtManager.VerifyEmailToken(token, userID); err != nil {
return err
}
// Get admin token
adminToken, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return k.handleError(err)
}
// Get user
user, err := k.client.GetUserByID(ctx, adminToken.AccessToken, k.realm, userID)
if err != nil {
return k.handleError(err)
}
// Set email as verified
user.EmailVerified = gocloak.BoolP(true)
// Update user
if err := k.client.UpdateUser(ctx, adminToken.AccessToken, k.realm, *user); err != nil {
return k.handleError(err)
}
return nil
}
func (k *Client) GetEmailVerificationStatus(ctx context.Context, userID string) (*auth.GetEmailVerificationResponse, error) {
// Get admin token
adminToken, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
// Get user
user, err := k.client.GetUserByID(ctx, adminToken.AccessToken, k.realm, userID)
if err != nil {
return nil, k.handleError(err)
}
// Check if email is verified
verified := false
if user.EmailVerified != nil {
verified = *user.EmailVerified
}
return &auth.GetEmailVerificationResponse{
EmailVerified: verified,
}, nil
}
func (k *Client) GetUserEmailVerificationToken(ctx context.Context, uid string) (*auth.GetUserTokenResponse, error) {
// ะŸะพะปัƒั‡ะตะฝะธะต ั‚ะพะบะตะฝะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ
adminToken, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
// ะŸะพะปัƒั‡ะฐะตะผ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั, ั‡ั‚ะพะฑั‹ ัƒะฑะตะดะธั‚ัŒัั ะฒ ะตะณะพ ััƒั‰ะตัั‚ะฒะพะฒะฐะฝะธะธ
_, err = k.client.GetUserByID(ctx, adminToken.AccessToken, k.realm, uid)
if err != nil {
return nil, k.handleError(err)
}
// ะกะพะทะดะฐั‘ะผ ะบะฐัั‚ะพะผะฝั‹ะน JWT-ั‚ะพะบะตะฝ ะดะปั ะฒะตั€ะธั„ะธะบะฐั†ะธะธ email ัั€ะพะบะพะผ ะฝะฐ 24 ั‡ะฐัะฐ
claims := jwt.MapClaims{
"sub": uid, // Subject (user ID)
"iat": time.Now().Unix(), // Issued at
"exp": time.Now().Add(24 * time.Hour).Unix(), // Expires in 24 hours
"purpose": "email_verification", // Custom claim to identify purpose
"iss": "molva-api-gateway", // Issuer
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// ะ˜ัะฟะพะปัŒะทัƒะตะผ ะบะปะธะตะฝั‚ัะบะธะน ัะตะบั€ะตั‚ ะบะฐะบ ะบะปัŽั‡ ะฟะพะดะฟะธัะธ
signedToken, err := token.SignedString([]byte(k.clientSecret))
if err != nil {
return nil, ErrInternal
}
return &auth.GetUserTokenResponse{
AccessToken: signedToken,
}, nil
}
func (k *Client) ResetPassword(ctx context.Context, request auth.ResetPasswordRequest) error {
token, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return k.handleError(err)
}
user, err := k.client.GetUsers(
ctx,
token.AccessToken,
k.realm,
gocloak.GetUsersParams{
Username: gocloak.StringP(request.Email),
},
)
if err != nil {
return k.handleError(err)
}
if len(user) == 0 {
return ErrNotFound
}
if err := k.client.SetPassword(
ctx,
token.AccessToken,
*user[0].ID,
k.realm,
request.NewPassword,
false,
); err != nil {
return k.handleError(err)
}
return nil
}

View File

@@ -0,0 +1,15 @@
package keycloak
import (
"github.com/Nerzal/gocloak/v13"
)
type Client struct {
client *gocloak.GoCloak
jwtManager TokenManager
env string
realm string
clientId string
clientSecret string
}

View File

@@ -0,0 +1,38 @@
package keycloak
const (
PatronymicAttr = "patronymic"
PermissionLevelCanEdit = "can_edit"
PermissionLevelCanView = "can_view"
PermissionLevelNoPermission = "no_permission"
UserGroupAgents = "agents"
UserGroupDistributor = "distributors"
UserAgentType = 0
UserDistributorType = 1
PermissionBalance = "balance"
PermissionCompany = "company"
PermissionEmployees = "employees"
PermissionProfile = "profile"
PermissionSubmissions = "submissions"
PermissionVacancies = "vacancies"
)
var (
permissionsSlice = []string{
PermissionBalance,
PermissionCompany,
PermissionEmployees,
PermissionProfile,
PermissionSubmissions,
PermissionVacancies,
}
)
type (
attributes map[string][]string
permissions map[string]string
)

View File

@@ -0,0 +1,16 @@
package keycloak
import "errors"
var (
ErrInvalidToken = errors.New("invalid token")
ErrInternal = errors.New("internal error")
ErrRealmClientNotFound = errors.New("realm client not found")
ErrRealmClientUnauthorized = errors.New("realm client unauthorized")
ErrUnknownUserType = errors.New("unknown user type")
ErrNoAttributes = errors.New("no attributes")
ErrAlreadyExists = errors.New("user already exists")
ErrBadRequest = errors.New("bad request")
ErrNotFound = errors.New("user not found")
ErrMissingPermission = errors.New("missing one or more permissions")
)

View File

@@ -0,0 +1,51 @@
package keycloak
import (
"errors"
"net/http"
"github.com/Nerzal/gocloak/v13"
)
type Config struct {
Env string
AuthServerAddr string
Realm string
ClientId string
ClientSecret string
}
func New(c *Config) (*Client, error) {
cli := &Client{
client: gocloak.NewClient(c.AuthServerAddr),
jwtManager: NewJWTManager(),
env: c.Env,
realm: c.Realm,
clientId: c.ClientId,
clientSecret: c.ClientSecret,
}
return cli, nil
}
func (k *Client) handleError(err error) error {
var apiErr *gocloak.APIError
if !errors.As(err, &apiErr) {
return ErrInternal
}
switch apiErr.Code {
case http.StatusNotFound:
return ErrRealmClientNotFound
case http.StatusUnauthorized:
return ErrRealmClientUnauthorized
case http.StatusConflict:
return ErrAlreadyExists
case http.StatusBadRequest:
return ErrBadRequest
default:
return ErrInternal
}
}

View File

@@ -0,0 +1,76 @@
package keycloak
import (
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/auth"
"github.com/golang-jwt/jwt/v5"
"time"
)
type TokenManager interface {
GetUserInfoFromToken(token string) (*auth.UserInfoFromToken, error)
VerifyEmailToken(token string, expectedUserID string) error
}
type tokenManager struct {
parser *jwt.Parser
}
func NewJWTManager() TokenManager {
return &tokenManager{
parser: jwt.NewParser(),
}
}
func (t *tokenManager) GetUserInfoFromToken(token string) (*auth.UserInfoFromToken, error) {
userToken, _, err := t.parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return nil, ErrInvalidToken
}
if claims, ok := userToken.Claims.(jwt.MapClaims); ok {
resp := auth.UserInfoFromToken{}
resp.UserId, ok = claims["sub"].(string)
if !ok {
return nil, ErrInvalidToken
}
return &resp, nil
}
return nil, ErrInvalidToken
}
func (t *tokenManager) VerifyEmailToken(token string, expectedUserID string) error {
// Parse token without verification (we'll verify signature manually)
parsedToken, _, err := t.parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return ErrInvalidToken
}
// Extract claims
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return ErrInvalidToken
}
// Check if token is for email verification
purpose, ok := claims["purpose"].(string)
if !ok || purpose != "email_verification" {
return ErrInvalidToken
}
// Check if subject matches expected user ID
sub, ok := claims["sub"].(string)
if !ok || sub != expectedUserID {
return ErrInvalidToken
}
// Check expiration
exp, ok := claims["exp"].(float64)
if !ok || float64(time.Now().Unix()) > exp {
return ErrInvalidToken
}
return nil
}

257
internal/broker/rmq.go Normal file
View File

@@ -0,0 +1,257 @@
package broker
import (
"context"
"errors"
"fmt"
"log/slog"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/constants"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/config"
requestmodel "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/request_model"
"github.com/google/uuid"
amqp "github.com/rabbitmq/amqp091-go"
)
var conn *amqp.Connection
var (
NotificationQueue string
)
type Broker interface {
Connect() error
Disconnect() error
}
type RabbitMQBroker struct {
cfg *config.Broker
}
func NewRabbitMQBroker(cfg *config.Broker) *RabbitMQBroker {
NotificationQueue = cfg.NotificationsQueueName
return &RabbitMQBroker{
cfg: cfg,
}
}
func (r *RabbitMQBroker) Connect() error {
var err error
rabbitConn := fmt.Sprintf(
"amqp://%s:%s@%s:%d",
r.cfg.UserName,
r.cfg.Password,
r.cfg.Host,
r.cfg.Port,
)
conn, err = amqp.Dial(rabbitConn)
return err
}
func (r *RabbitMQBroker) Disconnect() error {
return conn.Close()
}
func produceMessage(qName, replyToQName, corrId string, message []byte, headers amqp.Table) (err error) {
ch, err := conn.Channel()
if err != nil {
return err
}
defer func(ch *amqp.Channel) {
if errClose := ch.Close(); errClose != nil {
err = errors.Join(err, errClose)
}
}(ch)
q, err := ch.QueueDeclare(
qName, // name
true, // durable
false, // delete when unused
false, // exclusive
false, // no-wait
nil, // arguments
)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), constants.DefaultContextTimeout)
defer cancel()
err = ch.PublishWithContext(ctx,
"", // exchange
q.Name, // routing key
false, // mandatory
false, // immediate
amqp.Publishing{
DeliveryMode: amqp.Persistent,
Type: headers["command"].(string),
ReplyTo: replyToQName,
CorrelationId: corrId,
Body: message,
Headers: headers,
},
)
if err != nil {
return err
}
return nil
}
func consumeMessage(qName string, corrId string) (resp []byte, err error) {
ch, err := conn.Channel()
if err != nil {
return nil, err
}
defer func(ch *amqp.Channel) {
if errClose := ch.Close(); errClose != nil {
err = errors.Join(err, errClose)
}
}(ch)
if _, err = ch.QueueDeclare(
qName, // name
true, // durable
true, // delete when unused
false, // exclusive
false, // no-wait
nil, // arguments
); err != nil {
return nil, err
}
messages, err := ch.Consume(
qName, // queue
"", // consumer
false, // auto-ack
false, // exclusive
false, // no-local
false, // no-wait
nil, // args
)
if err != nil {
return nil, err
}
response := <-messages
//todo: ะŸะพะฟั€ะฐะฒะธั‚ัŒ ะฝะฐ ะฟั€ะพะฒะตั€ะบัƒ correlationId
// if d.CorrelationId == corrId {
// return nil, fmt.Errorf("invalid correlation ID %s", d.CorrelationId)
// }
if err = response.Ack(false); err != nil {
return nil, err
}
return response.Body, nil
}
func ProcessRequest(qName, replyToQName, corrId string, headers amqp.Table, message []byte, log *slog.Logger) ([]byte, error) {
log.Info("produce request to: ",
slog.String("qName", qName),
slog.String("replyToQName", replyToQName),
slog.String("command", headers["command"].(string)),
slog.String("corrId", corrId),
)
if err := produceMessage(qName, replyToQName, corrId, message, headers); err != nil {
log.Error("got some error while producing message: ",
slog.String("error", err.Error()),
slog.String("corrId", corrId))
return nil, err
}
log.Info("consume response from: ",
slog.String("qName", qName),
slog.String("replyToQName", replyToQName),
slog.String("command", headers["command"].(string)),
slog.String("corrId", corrId),
)
body, err := consumeMessage(replyToQName, corrId)
if err != nil {
log.Error("got some error while consuming message: ",
slog.String("error", err.Error()),
slog.String("corrId", corrId))
return body, err
}
return body, nil
}
func BuildAndProcessRequest(qName string, headers requestmodel.Header, message []byte, log *slog.Logger) ([]byte, error) {
queueUuid := uuid.New().String()
resp, err := ProcessRequest(
qName,
queueUuid,
"0",
headers.ToTable(),
message,
log,
)
return resp, err
}
func SendNotification(qName, messageType string, message []byte, log *slog.Logger) error {
ch, err := conn.Channel()
if err != nil {
return err
}
defer func(ch *amqp.Channel) {
if errClose := ch.Close(); errClose != nil {
err = errors.Join(err, errClose)
}
}(ch)
q, err := ch.QueueDeclare(
qName, // name
true, // durable
false, // delete when unused
false, // exclusive
false, // no-wait
nil, // arguments
)
if err != nil {
log.Error("error while sending notification message",
slog.String("error", err.Error()),
slog.String("qName", qName))
return err
}
ctx, cancel := context.WithTimeout(context.Background(), constants.DefaultContextTimeout)
defer cancel()
err = ch.PublishWithContext(ctx,
"", // exchange
q.Name, // routing key
false, // mandatory
false, // immediate
amqp.Publishing{
Type: messageType,
Body: message,
},
)
if err != nil {
log.Error("error while sending notification message",
slog.String("error", err.Error()),
slog.String("qName", qName))
return err
}
return nil
}

16
internal/cache/client.go vendored Normal file
View File

@@ -0,0 +1,16 @@
package cache
import "fmt"
func New(c Config, t Type) (Client, error) {
switch t {
case ValkeyCacheType:
return newValKeyCache(c)
default:
return nil, ErrUnknownCacheType
}
}
func getKey(k string, t ValueType) string {
return fmt.Sprintf("%s:%s", t, k)
}

16
internal/cache/errors.go vendored Normal file
View File

@@ -0,0 +1,16 @@
package cache
import "errors"
var (
ErrUnknownCacheType = errors.New("unknown cache type")
ErrInvalidConfig = errors.New("invalid config")
ErrConnect = errors.New("connecting to server")
ErrTlsConfig = errors.New("tls config")
ErrInvalidInstance = errors.New("check instance failed")
ErrKeyNotFound = errors.New("key not found")
ErrKeyExists = errors.New("key already exists")
ErrGet = errors.New("failed to get value")
ErrSet = errors.New("failed to set value")
ErrDel = errors.New("failed to delete value")
)

53
internal/cache/types.go vendored Normal file
View File

@@ -0,0 +1,53 @@
package cache
import (
"context"
"time"
)
type Client interface {
// Set ะดะพะฑะฐะฒะปัะตั‚ ะฒ ะบะตัˆ ะฟะพะด ะบะปัŽั‡ะตะผ key ะทะฝะฐั‡ะตะฝะธะต value
//
// ะ•ัะปะธ ั‚ะฐะบะพะณะพ ะบะปัŽั‡ะฐ ั ั‚ะฐะบะธะผ ั‚ะธะฟะพะผ ะทะฝะฐั‡ะตะฝะธั ะฝะตั‚,
// ั‚ะพ ะฒะพะทะฒั€ะฐั‰ะฐะตั‚ ะพัˆะธะฑะบัƒ ErrKeyExists
Set(ctx context.Context, key string, valueType ValueType, value any, expiration time.Duration) error
// Del ัƒะดะฐะปัะตั‚ ะทะฝะฐั‡ะตะฝะธะต ะฟะพ ะบะปัŽั‡ัƒ key ะธ ัƒะบะฐะทะฐะฝะฝะพะผัƒ valueType
//
// ะ•ัะปะธ ั‚ะฐะบะพะณะพ ะบะปัŽั‡ะฐ ั ั‚ะฐะบะธะผ ั‚ะธะฟะพะผ ะทะฝะฐั‡ะตะฝะธั ะฝะตั‚,
// ั‚ะพ ะฒะพะทะฒั€ะฐั‰ะฐะตั‚ ะพัˆะธะฑะบัƒ ErrKeyNotFound
Del(ctx context.Context, key string, valueType ValueType) error
// Get ะฒะพะทะฒั€ะฐั‰ะฐะตั‚ ะทะฝะฐั‡ะตะฝะธะต ะฟะพ ะบะปัŽั‡ัƒ key ะธ valueType
//
// ะ•ัะปะธ ั‚ะฐะบะพะณะพ ะบะปัŽั‡ะฐ ั ั‚ะฐะบะธะผ ั‚ะธะฟะพะผ ะทะฝะฐั‡ะตะฝะธั ะฝะตั‚,
// ั‚ะพ ะฒะพะทะฒั€ะฐั‰ะฐะตั‚ ะพัˆะธะฑะบัƒ ErrKeyNotFound
//
// ะ’ ัะปัƒั‡ะฐะต, ะตัะปะธ ะฟะพะปัƒั‡ะฐะตะผะพะต ะทะฝะฐั‡ะตะฝะธะต ะฝะต ัะฒะปัะตั‚ัั ัั‚ั€ะพะบะพะน,
// ะฝะตะพะฑั…ะพะดะธะผะพ ะฒะพัะฟะพะปัŒะทะพะฒะฐั‚ัŒัั ัะพะพั‚ะฒะตั‚ัะฒัƒัŽั‰ะธะผ ะผะตั‚ะพะดะพะผ ะฟะพะด
// ัั‚ะพั‚ ั‚ะธะฟ ะดะฐะฝะฝั‹ั…
Get(ctx context.Context, key string, valueType ValueType) (string, error)
// TODO: ะŸะพ ะฝะตะพะฑั…ะพะดะธะผะพัั‚ะธ ั€ะฐััˆะธั€ะธั‚ัŒ ะฝะตะพะฑั…ะพะดะธะผั‹ะผะธ ะผะตั‚ะพะดะฐะผะธ
}
type (
Type uint
ValueType string
Config any
)
const (
ValkeyCacheType Type = iota
)
const (
LogoValueType ValueType = "value_logo_type"
DocumentsValueType ValueType = "value_documents_type"
PswResetOTPValueType ValueType = "value_otp_type"
PswResetTokenValueType ValueType = "value_token_type"
IntegrationCompanyValueType ValueType = "integration:company"
IntegrationVacancyValueType ValueType = "integration:vacancy"
)

143
internal/cache/valkey.go vendored Normal file
View File

@@ -0,0 +1,143 @@
package cache
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"time"
"github.com/go-redis/redis/v7"
)
type ValKeyCacheConfig struct {
Addrs []string
Password string
ReadOnly bool
DialTimeout time.Duration
PoolSize int
DefaultTTL time.Duration
RootCaFilePath string
}
type valkeyClient interface {
Set(key string, value any, expiration time.Duration) *redis.StatusCmd
Get(key string) *redis.StringCmd
Del(keys ...string) *redis.IntCmd
Exists(keys ...string) *redis.IntCmd
Ping() *redis.StatusCmd
}
type valKeyCache struct {
config ValKeyCacheConfig
client valkeyClient
}
func newValKeyCache(c Config) (*valKeyCache, error) {
cfg, ok := c.(ValKeyCacheConfig)
if !ok {
return nil, ErrInvalidConfig
}
caCert, err := os.ReadFile(cfg.RootCaFilePath)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrTlsConfig, err.Error())
}
caCertPool := x509.NewCertPool()
if ok := caCertPool.AppendCertsFromPEM(caCert); !ok {
return nil, fmt.Errorf("%w: %s", ErrTlsConfig, "failed to append CA cert to pool")
}
tlsConfig := &tls.Config{
RootCAs: caCertPool,
MinVersion: tls.VersionTLS12,
}
client := redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: cfg.Addrs,
Password: cfg.Password,
ReadOnly: cfg.ReadOnly,
DialTimeout: cfg.DialTimeout,
PoolSize: cfg.PoolSize,
TLSConfig: tlsConfig,
})
if err := client.Ping().Err(); err != nil {
return nil, fmt.Errorf("%w: %v", ErrConnect, err)
}
return &valKeyCache{
config: cfg,
client: client,
}, nil
}
func (c *valKeyCache) Get(ctx context.Context, key string, valueType ValueType) (string, error) {
if err := c.checkInstance(); err != nil {
return "", fmt.Errorf("%w: %v", ErrGet, err)
}
val, err := c.client.Get(getKey(key, valueType)).Result()
if err == redis.Nil {
return "", ErrKeyNotFound
}
if err != nil {
return "", fmt.Errorf("%w: %v", ErrGet, err)
}
return val, nil
}
func (c *valKeyCache) Set(ctx context.Context, key string, valueType ValueType, value any, expiration time.Duration) error {
if err := c.checkInstance(); err != nil {
return fmt.Errorf("%w: %v", ErrSet, err)
}
key = getKey(key, valueType)
if exists, err := c.client.Exists(key).Result(); err != nil || exists == 1 {
if exists == 1 {
return ErrKeyExists
}
return fmt.Errorf("%w: %v", ErrSet, err)
}
if err := c.client.Set(key, value, expiration).Err(); err != nil {
return fmt.Errorf("%w: %v", ErrSet, err)
}
return nil
}
func (c *valKeyCache) Del(ctx context.Context, key string, valueType ValueType) error {
if err := c.checkInstance(); err != nil {
return fmt.Errorf("%w: %v", ErrDel, err.Error())
}
if res, err := c.client.Del(getKey(key, valueType)).Result(); err != nil || res == 0 {
if res == 0 {
return ErrKeyNotFound
}
return fmt.Errorf("%w: %s", ErrDel, err.Error())
}
return nil
}
func (c *valKeyCache) checkInstance() error {
if c == nil {
return ErrInvalidInstance
}
if s := c.client.Ping(); s.Err() != nil {
return fmt.Errorf("%w: %v", ErrInvalidInstance, s.Err().Error())
}
return nil
}

53
internal/config/config.go Normal file
View File

@@ -0,0 +1,53 @@
package config
import (
"errors"
"fmt"
"os"
"github.com/ilyakaznacheev/cleanenv"
)
var (
ErrInternal = errors.New("can not load config")
ErrMarshaling = errors.New("can not marshal config path")
)
func MustLoad(version, commit, date string) *Config {
cfg, err := loadConfig()
if err != nil {
panic(err)
}
cfg.Build.Version = version
cfg.Build.Commit = commit
cfg.Build.Date = date
return cfg
}
func loadConfig() (*Config, error) {
if _, err := os.Stat(ConfigBasePath); os.IsNotExist(err) {
return nil, fmt.Errorf("no config file found")
}
var localConfig LocalDeploy
if err := cleanenv.ReadConfig(ConfigBasePath, &localConfig); err != nil {
return nil, fmt.Errorf("error reading local config: %w", err)
}
for _, env := range localConfig.Deploy.Env {
if err := os.Setenv(env.Name, env.Value); err != nil {
return nil, fmt.Errorf("error setting env %s: %w", env.Name, err)
}
}
var cfg Config
if err := cleanenv.ReadConfig(ConfigBasePath, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}

View File

@@ -0,0 +1,5 @@
package config
const (
ConfigBasePath = "/home/alexorel/work/Molva/molva-backend/.build/config/local.example.yaml"
)

View File

@@ -0,0 +1,99 @@
package config
import "time"
type (
Config struct {
Env string `yaml:"env" env-default:"production"`
Host string `yaml:"host" env-default:"localhost"`
Port int `yaml:"port" env-default:"8000"`
Keycloak Keycloak
Broker Broker `yaml:"broker"`
Secret SecretConfig
EmailVerificationService EmailVerificationService `yaml:"emailVerificationService"`
ValkeyCache ValkeyCacheConfig `yaml:"cache"`
S3Storage S3StorageConfig `yaml:"s3Storage"`
Database Database `yaml:"database"`
Integration Integration
Build BuildInfo
}
BuildInfo struct {
Version string
Commit string
Date string
}
ValkeyCacheConfig struct {
Addrs string `yaml:"-" env:"CACHE_ADDRS" env-default:"localhost:6379"`
Password string `yaml:"-" env:"CACHE_PASSWORD" env-default:""`
ReadOnly bool `yaml:"readOnly" env-default:"false"`
DialTimeout time.Duration `yaml:"dialTimeout" env-default:"5s"`
PoolSize int `yaml:"poolSize" env-default:"10"`
DefaultTTL time.Duration `yaml:"defaultTtl" env-default:"1h"`
RootCaFilePath string `yaml:"rootCaFilePath" env-default:""`
}
S3StorageConfig struct {
Bucket string `yaml:"bucket" env-default:""`
DefaultLinkTTL time.Duration `yaml:"defaultLinkTtl" env-default:"1h"`
}
SecretConfig struct {
Key string `env:"SHORTENER_SECRET_KEY"` // 32-byte key
}
Keycloak struct {
BaseURL string `env:"KEYCLOAK_BASE_URL" env-default:"http://localhost"`
Realm string `env:"KEYCLOAK_REALM" env-default:""`
ClientId string `env:"KEYCLOAK_CLIENT_ID" env-default:""`
ClientSecret string `env:"KEYCLOAK_CLIENT_SECRET" env-default:""`
}
Broker struct {
Host string `yaml:"-" env:"BROKER_HOST" env-default:"amqp://user:password@localhost"`
Port int `yaml:"-" env:"BROKER_PORT" env-default:"5672"`
UserName string `yaml:"-" env:"BROKER_USERNAME" env-default:"user"`
Password string `yaml:"-" env:"BROKER_PASSWORD" env-default:"password"`
NotificationsQueueName string `yaml:"notificationsQueueName" env-default:"notifications"`
}
// TODO: ั‡ะต ั ัั‚ะธะผ ะดะตะปะฐั‚ัŒ? ะ•ะณะพ ะฝะธะณะดะต ะฝะตั‚ (ะฝะธ ะฝะฐ ะดะตะฒ, ะฝะธ ะฝะฐ ะฟั€ะพะด)
EmailVerificationService struct {
APIKey string `yaml:"-" env-default:""`
}
Database struct {
Host string `yaml:"-" env:"DB_HOSTS"`
Port uint16 `yaml:"-" env:"DB_PORT"`
Username string `yaml:"-" env:"DB_USERNAME"`
Password string `yaml:"-" env:"DB_PASSWORD"`
DBName string `yaml:"-" env:"DB_DBNAME"`
Schema string `yaml:"schema"`
SSLMode string `yaml:"sslmode" env-default:"verify-full"`
RootCaFilePath string `yaml:"rootCaFilePath" env-default:"/root/YandexInternalRootCA.crt"`
}
Integration struct {
Vkusvill VkusvillIntegration `yaml:"vkusvill"`
}
VkusvillIntegration struct {
ApiToken string `yaml:"-" env:"INTEGRATION_VKUSVILL_API_TOKEN"`
}
)
type (
LocalDeploy struct {
Deploy LocalDeployConfig `yaml:"deploy"`
}
LocalDeployConfig struct {
Env []LocalDeployConfigElement `yaml:"env"`
}
LocalDeployConfigElement struct {
Name string `yaml:"name"`
Value string `yaml:"value"`
}
)

View File

@@ -0,0 +1,17 @@
package constants
import "errors"
var (
ErrInternalServerError = errors.New("internal server error")
ErrBadRequest = errors.New("bad request")
ErrForbidden = errors.New("forbidden")
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
ErrRequestTimeout = errors.New("request timeout")
ErrNotImplemented = errors.New("not implemented")
ErrBadGateway = errors.New("bad gateway")
ErrServiceUnavailable = errors.New("service unavailable")
ErrIntOutOfRange = errors.New("value is out of range for int32")
)

View File

@@ -0,0 +1,11 @@
package constants
const (
FeedEventsTableName = "feed_events"
ClientTableName = "client"
SubmissionTableName = "submission"
)
const (
DefaultFeedFilterLimit = 20
)

View File

@@ -0,0 +1,182 @@
package constants
const (
EmailNotificationMessageType = "email"
RegistrationNotificationMessageSubject = "ะ ะตะณะธัั‚ั€ะฐั†ะธั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ะฒ ัะธัั‚ะตะผะต ะฟั€ะธะปะพะถะตะฝะธั Molva"
RegistrationNewUserAdmin = "ะ—ะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝ ะฝะพะฒั‹ะน ะฟะพะปัŒะทะพะฒะฐั‚ะตะปัŒ ะฟั€ะธะปะพะถะตะฝะธั"
EmailVerificationMessageSubject = "ะŸะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะฐะดั€ะตัะฐ ัะปะตะบั‚ั€ะพะฝะฝะพะน ะฟะพั‡ั‚ั‹"
TextNotificationContentType = "text/plain"
HTMLNotificationContentType = "text/html"
RegistrationNotificationText = `
ะ”ะพะฑั€ะพ ะฟะพะถะฐะปะพะฒะฐั‚ัŒ ะฒ Molva!
ะ—ะดั€ะฐะฒัั‚ะฒัƒะนั‚ะต, %s!
ะกะฟะฐัะธะฑะพ ะทะฐ ั€ะตะณะธัั‚ั€ะฐั†ะธัŽ ะฒ Molva. ะœั‹ ั€ะฐะดั‹ ะฒะธะดะตั‚ัŒ ะฒะฐั ะฒ ะฝะฐัˆะตะผ ัะพะพะฑั‰ะตัั‚ะฒะต.
ะขะตะฟะตั€ัŒ ะฒั‹ ะผะพะถะตั‚ะต:
- ะŸะพะปัƒั‡ะฐั‚ัŒ ะฟะตั€ัะพะฝะฐะปะธะทะธั€ะพะฒะฐะฝะฝั‹ะต ัƒะฒะตะดะพะผะปะตะฝะธั.
- ะะฐัั‚ั€ะฐะธะฒะฐั‚ัŒ ัะฒะพะธ ะฟั€ะตะดะฟะพั‡ั‚ะตะฝะธั.
- ะ˜ัะฟะพะปัŒะทะพะฒะฐั‚ัŒ ะฒัะต ะฒะพะทะผะพะถะฝะพัั‚ะธ ะฟะปะฐั‚ั„ะพั€ะผั‹.
ะ•ัะปะธ ัƒ ะฒะฐั ะตัั‚ัŒ ะฒะพะฟั€ะพัั‹, ะฝะต ัั‚ะตัะฝัะนั‚ะตััŒ ะพะฑั€ะฐั‰ะฐั‚ัŒัั ะฒ ะฝะฐัˆัƒ ัะปัƒะถะฑัƒ ะฟะพะดะดะตั€ะถะบะธ!
ะŸะพัะปะต ะฟั€ะพะฒะตั€ะบะธ ะฐะบะบะฐัƒะฝั‚ะฐ ะฝะฐัˆ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ ั ะฒะฐะผะธ ัะฒัะถะตั‚ัั
ะก ัƒะฒะฐะถะตะฝะธะตะผ, ะบะพะผะฐะฝะดะฐ Molva.
ะญั‚ะพ ะฟะธััŒะผะพ ะพั‚ะฟั€ะฐะฒะปะตะฝะพ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ, ะฟะพะถะฐะปัƒะนัั‚ะฐ, ะฝะต ะพั‚ะฒะตั‡ะฐะนั‚ะต ะฝะฐ ะฝะตะณะพ.
`
//nolint:gosec // there are no [potentially hardcoded] credentials in this message
ForgotPasswordNotificationMessageSubject = "ะ’ะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ะฟะฐั€ะพะปั ะฒ ัะธัั‚ะตะผะต ะฟั€ะธะปะพะถะตะฝะธั Molva"
//nolint:gosec // there are no [potentially hardcoded] credentials in this message
ForgotPasswordNotificationText = `
ะ’ะฐัˆ ะบะพะด ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ะฟะฐั€ะพะปั: %s. ะ•ะณะพ ะผะพะถะฝะพ ะธัะฟะพะปัŒะทะพะฒะฐั‚ัŒ, ั‡ั‚ะพะฑั‹ ัะฑั€ะพัะธั‚ัŒ ะฟะฐั€ะพะปัŒ ะธ ะทะฐะนั‚ะธ ะฒ ัะฒะพะน ะฐะบะบะฐัƒะฝั‚ ะฒ Molva.
ะ•ัะปะธ ะ’ั‹ ะฝะต ะทะฐะฟั€ะฐัˆะธะฒะฐะปะธ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ะดะพัั‚ัƒะฟะฐ ะบ ะฐะบะบะฐัƒะฝั‚ัƒ, ะฟะพะถะฐะปัƒะนัั‚ะฐ, ะฟั€ะพะธะณะฝะพั€ะธั€ัƒะนั‚ะต ัั‚ะพ ะฟะธััŒะผะพ.
ะก ัƒะฒะฐะถะตะฝะธะตะผ, ะบะพะผะฐะฝะดะฐ Molva.
`
)
const EmailVerificationTemplate = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 20px; font-family: Arial, sans-serif; background-color: #f5f5f5;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 40px; border-radius: 8px;">
<h1 style="color: #333; font-size: 24px; margin: 0 0 30px 0;">MOLVA</h1>
<h2 style="color: #333; font-size: 20px; margin: 0 0 20px 0;">ะŸะพะดั‚ะฒะตั€ะดะธั‚ะต email</h2>
<p style="color: #555; font-size: 16px; line-height: 24px; margin: 0 0 10px 0;">
ะ—ะดั€ะฐะฒัั‚ะฒัƒะนั‚ะต, %s!
</p>
<p style="color: #555; font-size: 16px; line-height: 24px; margin: 0 0 30px 0;">
ะ”ะปั ะทะฐะฒะตั€ัˆะตะฝะธั ั€ะตะณะธัั‚ั€ะฐั†ะธะธ ะฟะพะดั‚ะฒะตั€ะดะธั‚ะต ะฒะฐัˆ email ะฐะดั€ะตั.
</p>
<a href="%s" style="display: inline-block; padding: 12px 30px; background-color: #4A90E2; color: #ffffff; text-decoration: none; border-radius: 4px; font-size: 16px;">
ะŸะพะดั‚ะฒะตั€ะดะธั‚ัŒ email
</a>
<p style="color: #999; font-size: 14px; line-height: 20px; margin: 30px 0 10px 0;">
ะ˜ะปะธ ัะบะพะฟะธั€ัƒะนั‚ะต ััั‹ะปะบัƒ:
</p>
<p style="color: #4A90E2; font-size: 14px; line-height: 20px; word-break: break-all; margin: 0 0 30px 0;">
%s
</p>
<p style="color: #999; font-size: 13px; line-height: 18px; margin: 0;">
ะกัั‹ะปะบะฐ ะดะตะนัั‚ะฒะธั‚ะตะปัŒะฝะฐ 24 ั‡ะฐัะฐ.
</p>
</div>
</body>
</html>`
const EmailConfirmationPage = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ะŸะพะดั‚ะฒะตั€ะถะดะตะฝะธะต Email</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f5f5f5;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.container {
background: #ffffff;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
width: 100%;
}
.title {
color: #333;
margin-bottom: 20px;
font-size: 22px;
}
.message {
color: #555;
font-size: 16px;
margin-bottom: 30px;
}
.close-note {
color: #777;
font-size: 14px;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="title" id="status-title">ะ˜ะดั‘ั‚ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต...</div>
<div class="message" id="status-message">ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะพะดะพะถะดะธั‚ะต.</div>
<div class="close-note" id="close-note" style="display:none;">
ะ’ั‹ ะผะพะถะตั‚ะต ะทะฐะบั€ั‹ั‚ัŒ ัั‚ะพ ะพะบะฝะพ.
</div>
</div>
<script>
(function() {
fetch("{{.VerificationURL}}", {
method: "PATCH",
headers: {
"Content-Type": "application/json"
}
})
.then(response => {
const titleEl = document.getElementById("status-title");
const msgEl = document.getElementById("status-message");
const closeEl = document.getElementById("close-note");
if (response.ok) {
titleEl.innerText = "Email ัƒัะฟะตัˆะฝะพ ะฟะพะดั‚ะฒะตั€ะถะดั‘ะฝ!";
msgEl.innerText = "ะกะฟะฐัะธะฑะพ ะทะฐ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต. ะขะตะฟะตั€ัŒ ะฒั‹ ะผะพะถะตั‚ะต ะฒะตั€ะฝัƒั‚ัŒัั ะฝะฐ ัะฐะนั‚ MOLVA!";
msgEl.style.color = "green";
} else if (response.status === 400 || response.status === 404) {
titleEl.innerText = "ะะตะฒะตั€ะฝะฐั ััั‹ะปะบะฐ ะธะปะธ ั‚ะพะบะตะฝ";
msgEl.innerText = "ะŸั€ะพะฒะตั€ัŒั‚ะต ะฟั€ะฐะฒะธะปัŒะฝะพัั‚ัŒ ััั‹ะปะบะธ ะธะปะธ ะทะฐะฟั€ะพัะธั‚ะต ะฝะพะฒัƒัŽ.";
msgEl.style.color = "red";
} else {
titleEl.innerText = "ะžัˆะธะฑะบะฐ ัะตั€ะฒะตั€ะฐ";
msgEl.innerText = "ะŸะพะฟั€ะพะฑัƒะนั‚ะต ะฟะพะฒั‚ะพั€ะธั‚ัŒ ะฟะพะทะดะฝะตะต.";
msgEl.style.color = "red";
}
closeEl.style.display = "block";
})
.catch(() => {
const titleEl = document.getElementById("status-title");
const msgEl = document.getElementById("status-message");
const closeEl = document.getElementById("close-note");
titleEl.innerText = "ะะต ัƒะดะฐะปะพััŒ ะฟะพะดะบะปัŽั‡ะธั‚ัŒัั";
msgEl.innerText = "ะŸั€ะพะฒะตั€ัŒั‚ะต ะธะฝั‚ะตั€ะฝะตั‚-ัะพะตะดะธะฝะตะฝะธะต ะธ ะฟะพะฟั€ะพะฑัƒะนั‚ะต ัะฝะพะฒะฐ.";
msgEl.style.color = "red";
closeEl.style.display = "block";
});
})();
</script>
</body>
</html>`
const EmailNewUserRegistrationAdminMessage = `
ะ—ะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝ ะฝะพะฒั‹ะน ะฟะพะปัŒะทะพะฒะฐั‚ะตะปัŒ. Email ะฟะพะดั‚ะฒะตั€ะถะดั‘ะฝ
Email: %s
Name: %s
Type: %s
`

View File

@@ -0,0 +1,38 @@
package constants
type PermissionType string
const (
Balance PermissionType = "balance"
Vacancies PermissionType = "vacancies"
Employees PermissionType = "employees"
Profile PermissionType = "profile"
Company PermissionType = "company"
Submissions PermissionType = "submissions"
)
func (p PermissionType) String() string {
return string(p)
}
type PermissionValue string
const (
NoPermission PermissionValue = "no_permission"
CanView PermissionValue = "can_view"
CanEdit PermissionValue = "can_edit"
)
func (p PermissionValue) String() string {
return string(p)
}
var PermissionLevels = map[PermissionValue]int{
NoPermission: 1,
CanView: 2,
CanEdit: 3,
}
var AllPermissions = []PermissionType{
Vacancies, Balance, Company, Submissions, Profile, Employees, Submissions,
}

27
internal/constants/s3.go Normal file
View File

@@ -0,0 +1,27 @@
package constants
import "time"
const (
LogoLinkTTL = 55 * time.Minute
LogoMaxSize = 10 << 20 // 10 MB
BytesToDetectContentType = 512
ParseMultipartFormAllFile = 0
TempDirPermission = 0750
TempLogoFilePattern = "logo-*.tmp"
)
const (
DefaultFileTTL = 30 * 24 * time.Hour //30 days
)
var ValidImageTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/webp": true,
}
const (
RefreshLogoCacheAsyncTimeout = 10 * time.Second
)

View File

@@ -0,0 +1,31 @@
package constants
import "time"
const (
EmailVerificationServiceURL = "https://emailvalidation.abstractapi.com/v1/"
DocumentsPath = "/opt/molva/docs"
UserFilesBasePath = "/opt/molva/user_files"
DefaultRetryTimeout = time.Second * 10
DefaultContextTimeout = 30 * time.Second
DefaultPaginationPageSize = 20
DefaultPaginationPage = 1
)
const (
AdminNotificationEmail = "information@molva.io"
AdminNotificationId = 0
)
var AdminNotificationEmails = []string{AdminNotificationEmail}
const (
UserTypeAgentName = "ะะณะตะฝั‚"
UserTypeDistributorName = "ะ”ะธัั‚ั€ะธะฑัŒัŽั‚ะพั€"
)
const (
DistributorClientType = 1
AgentClientType = 0
)

View File

@@ -0,0 +1,62 @@
package database
import (
"context"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
pgdb "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/postgres"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
)
type Client interface {
CreateUser(ctx context.Context, request *dbtypes.UserSaveRequest) (*dbtypes.UserSaveResponse, error)
GetClientValidation(ctx context.Context, request *dbtypes.ClientValidationGetRequest) (*dbtypes.ClientValidationGetResponse, error)
GetVacancyIntegrationInfoById(ctx context.Context, vacancyId string) (string, *dbtypes.VacancyExtraFieldsTemplate, error)
GetCompanyMetadataById(ctx context.Context, companyId string) (*dbtypes.CompanyMetadata, *dbtypes.CompanyExtraFieldsTemplate, error)
GetVacancyList(ctx context.Context, request *dbtypes.VacancyListGetRequest) (*dbtypes.VacancyListGetResponse, error)
CreateVacancy(ctx context.Context, request *dbtypes.VacancyCreateRequest) (*dbtypes.VacancyCreateResponse, error)
UpdateVacancy(ctx context.Context, request *dbtypes.VacancyUpdateRequest) (*dbtypes.VacancyUpdateResponse, error)
DeleteVacancy(ctx context.Context, request *dbtypes.VacancyDeleteRequest) (*dbtypes.VacancyDeleteResponse, error)
SendVacancyToModeration(ctx context.Context, request *dbtypes.SendVacancyToModerationRequest) (*dbtypes.SendVacancyToModerationResponse, error)
GetSubmissionList(ctx context.Context, request *dbtypes.SubmissionListGetRequest) (*dbtypes.SubmissionListGetResponse, error)
CreateSubmission(ctx context.Context, request *dbtypes.SubmissionCreateRequest) (*dbtypes.SubmissionCreateResponse, error)
UpdateSubmissionStatus(ctx context.Context, request *dbtypes.SubmissionStatusUpdateRequest) (*dbtypes.SubmissionStatusUpdateResponse, error)
DeleteSubmission(ctx context.Context, request *dbtypes.SubmissionDeleteRequest) (*dbtypes.SubmissionDeleteResponse, error)
GetCompanyList(ctx context.Context, request *dbtypes.CompanyListGetRequest) (*dbtypes.CompanyListGetResponse, error)
GetCompanyById(ctx context.Context, request *dbtypes.CompanyByIdGetRequest) (*dbtypes.CompanyByIdGetResponse, error)
CreateCompany(ctx context.Context, request *dbtypes.CompanyCreateRequest) (*dbtypes.CompanyCreateResponse, error)
UpdateCompany(ctx context.Context, request *dbtypes.CompanyUpdateRequest) (*dbtypes.CompanyUpdateResponse, error)
// DeleteDistributorCompany(ctx context.Context, request *dbtypes.DistributorCompanyDeleteRequest) (*dbtypes.DistributorCompanyDeleteResponse, error)
GetProfileById(ctx context.Context, request *dbtypes.ProfileGetRequest) (*dbtypes.ProfileGetResponse, error)
UpdateProfile(ctx context.Context, request *dbtypes.ProfileUpdateRequest) (*dbtypes.ProfileUpdateResponse, error)
GetBalance(ctx context.Context, request *dbtypes.BalanceGetRequest) (*dbtypes.BalanceGetResponse, error)
GetTransactionList(ctx context.Context, request *dbtypes.TransactionListGetRequest) (*dbtypes.TransactionListGetResponse, error)
CreateTransaction(ctx context.Context, request *dbtypes.TransactionCreateRequest) (*dbtypes.TransactionCreateResponse, error)
GetBankAccountList(ctx context.Context, request *dbtypes.BankAccountListGetRequest) (*dbtypes.BankAccountListGetResponse, error)
CreateBankAccount(ctx context.Context, request *dbtypes.BankAccountCreateRequest) (*dbtypes.BankAccountCreateResponse, error)
UpdateBankAccount(ctx context.Context, request *dbtypes.BankAccountUpdateRequest) (*dbtypes.BankAccountUpdateResponse, error)
DeleteBankAccount(ctx context.Context, request *dbtypes.BankAccountDeleteRequest) (*dbtypes.BankAccountDeleteResponse, error)
}
func New(cType ClientType, c Config) (Client, error) {
switch cType {
case PostgresClientType:
cfg, ok := c.(pgdb.PostgresConfig)
if !ok {
return nil, dberrors.ErrInvalidConfig
}
return pgdb.NewClient(cfg)
default:
return nil, dberrors.ErrClientType
}
}

View File

@@ -0,0 +1,10 @@
package database
type (
ClientType int
Config any
)
const (
PostgresClientType ClientType = iota
)

View File

@@ -0,0 +1,16 @@
package dberrors
import "errors"
var (
ErrClientType = errors.New("invalid client type")
ErrInvalidConfig = errors.New("invalid config")
ErrUnimplemented = errors.New("unimplemented")
ErrBadRequest = errors.New("bad request")
ErrForbidden = errors.New("forbidden")
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrInternal = errors.New("internal")
)

View File

@@ -0,0 +1,100 @@
package pgdb
import (
"context"
"database/sql"
"errors"
"fmt"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"github.com/Masterminds/squirrel"
)
func (c *client) GetBalance(
ctx context.Context,
request *dbtypes.BalanceGetRequest,
) (*dbtypes.BalanceGetResponse, error) {
if request == nil {
return nil, nil
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
balancesTable = fmt.Sprintf("%s.%s", c.config.Schema, BalancesTableName)
)
getBalance := psql.Select(
"raw_balance", "clean_balance",
).From(balancesTable).
Where(squirrel.Eq{"owner_id": request.OwnerId})
query, args, err := getBalance.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get balance query: %v", dberrors.ErrInternal, err)
}
row := c.db.QueryRowContext(ctx, query, args...)
var (
balance dbtypes.Balance
)
if err := row.Scan(&balance.RawBalance, &balance.CleanBalance); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
return &dbtypes.BalanceGetResponse{
Balance: &balance,
}, nil
}
func (c *client) createBalance(
ctx context.Context,
driver Driver,
request *dbtypes.BalanceCreateRequest,
) error {
if request == nil {
return fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
balancesTable = fmt.Sprintf("%s.%s", c.config.Schema, BalancesTableName)
)
createBalance := psql.Insert(balancesTable).
Columns(
"id", "owner_id", "raw_balance", "clean_balance",
).
Values(
request.Id, request.OwnerId, request.RawBalance, request.CleanBalance,
)
query, args, err := createBalance.ToSql()
if err != nil {
return fmt.Errorf("%w: error building 'create balance' query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("%w: error executing 'create balance' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("%w: error getting rows affected for 'create balance' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return dberrors.ErrInternal
}
return nil
}

View File

@@ -0,0 +1,313 @@
package pgdb
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
)
// ALERT: intergrate safety checks
func (c *client) GetBankAccountList(
ctx context.Context,
request *dbtypes.BankAccountListGetRequest,
) (*dbtypes.BankAccountListGetResponse, error) {
if request == nil {
return nil, nil
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
bankAccountsTable = fmt.Sprintf("%s.%s", c.config.Schema, BankAccountsTableName)
)
getAccounts := psql.Select(
"id", "owner_id", "account_number", "bank_name", "bik",
"correspondent_account", "is_primary", "created_at", "updated_at",
).From(bankAccountsTable).
Where(squirrel.Eq{"owner_id": request.OwnerId})
query, args, err := getAccounts.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get bank accounts query: %v", dberrors.ErrInternal, err)
}
rows, err := c.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing get bank accounts query: %v", dberrors.ErrInternal, err)
}
defer rows.Close()
var accounts []dbtypes.BankAccount
for rows.Next() {
var (
accountNumber, bankName, bik, correspondentAccount sql.NullString
bankAccount dbtypes.BankAccount
)
if err := rows.Scan(
&bankAccount.Id, &bankAccount.OwnerId, &accountNumber, &bankName, &bik,
&correspondentAccount, &bankAccount.IsPrimary, &bankAccount.CreatedAt, &bankAccount.UpdatedAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
if accountNumber.Valid {
bankAccount.AccountName = accountNumber.String
}
if bankName.Valid {
bankAccount.BankName = bankName.String
}
if bik.Valid {
bankAccount.Bik = bik.String
}
if correspondentAccount.Valid {
bankAccount.CorrespondentAccount = correspondentAccount.String
}
accounts = append(accounts, bankAccount)
}
return &dbtypes.BankAccountListGetResponse{
BankAccounts: accounts,
}, nil
}
func (c *client) CreateBankAccount(
ctx context.Context,
request *dbtypes.BankAccountCreateRequest,
) (*dbtypes.BankAccountCreateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
bankAccountsTable = fmt.Sprintf("%s.%s", c.config.Schema, BankAccountsTableName)
)
// TODO: use normal uuid after DB reengineering
bankAccountId := fmt.Sprintf("%sBNK", strings.ReplaceAll(uuid.NewString(), "-", ""))
createBankAccount := psql.Insert(bankAccountsTable).
Columns(
"id", "owner_id", "account_number", "bank_name", "bik", "correspondent_account", "is_primary",
).
Values(
bankAccountId, request.OwnerId, request.AccountNumber, request.BankName,
request.Bik, request.CorrespondentAccount, request.IsPrimary,
)
query, args, err := createBankAccount.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building create bank account query: %v", dberrors.ErrInternal, err)
}
res, err := c.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing create bank account query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for create bank account query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrInternal
}
return &dbtypes.BankAccountCreateResponse{
Id: bankAccountId,
}, nil
}
func (c *client) UpdateBankAccount(
ctx context.Context,
request *dbtypes.BankAccountUpdateRequest,
) (*dbtypes.BankAccountUpdateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("%w: error starting transaction: %v", dberrors.ErrInternal, err)
}
defer func() { _ = tx.Rollback() }()
result, err := c.updateBankAccount(ctx, tx, request)
if err != nil {
return nil, fmt.Errorf("error updating bank account: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("%w: error committing transaction: %w", dberrors.ErrInternal, err)
}
return result, nil
}
func (c *client) updateBankAccount(
ctx context.Context,
driver Driver,
request *dbtypes.BankAccountUpdateRequest,
) (*dbtypes.BankAccountUpdateResponse, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
bankAccountsTable = fmt.Sprintf("%s.%s", c.config.Schema, BankAccountsTableName)
)
updateAccount := psql.Update(bankAccountsTable).
Where(squirrel.Eq{"id": request.Id})
if request.AccountNumber != nil {
updateAccount = updateAccount.Set("account_number", *request.AccountNumber)
}
// TODO: uncomment when DB supports it
// if request.AccountName != nil {
// updateAccount = updateAccount.Set("account_name", *request.AccountName)
// }
if request.BankName != nil {
updateAccount = updateAccount.Set("bank_name", *request.BankName)
}
if request.Bik != nil {
updateAccount = updateAccount.Set("bik", *request.Bik)
}
if request.CorrespondentAccount != nil {
updateAccount = updateAccount.Set("correspondent_account", *request.CorrespondentAccount)
}
if request.IsPrimary != nil {
if *request.IsPrimary {
if err := c.unmarkPrimaryBankAccount(ctx, driver, request.Id); err != nil {
return nil, fmt.Errorf("error unmarking currently primary bank account: %w", err)
}
}
// QnA: what if the update makes all BAs non-primary?
updateAccount = updateAccount.Set("is_primary", *request.IsPrimary)
}
query, args, err := updateAccount.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building 'update bank account' query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing 'update bank account' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for 'update bank account' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrNotFound
}
return &dbtypes.BankAccountUpdateResponse{}, nil
}
func (c *client) unmarkPrimaryBankAccount(
ctx context.Context,
driver Driver,
bankAccountId string,
) error {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
bankAccountsTable = fmt.Sprintf("%s.%s", c.config.Schema, BankAccountsTableName)
)
getOwnerId := psql.Select(
"owner_id", "is_primary",
).From(bankAccountsTable).
Where(squirrel.Eq{"id": bankAccountId}).
Suffix("FOR UPDATE")
query, args, err := getOwnerId.ToSql()
if err != nil {
return fmt.Errorf("%w: error building 'get owner id of bank account' query: %v", dberrors.ErrInternal, err)
}
row := driver.QueryRowContext(ctx, query, args...)
var (
ownerId string
isPrimary bool
)
if err := row.Scan(&ownerId, &isPrimary); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return dberrors.ErrNotFound
}
return fmt.Errorf("%w: error scanning row for 'get owner id of bank account' query: %v", dberrors.ErrInternal, err)
}
if isPrimary {
return nil
}
unmarkPrimaryBA := psql.Update(bankAccountsTable).
Set("is_primary", false).
Where(squirrel.Eq{
"owner_id": ownerId,
"is_primary": true,
})
query, args, err = unmarkPrimaryBA.ToSql()
if err != nil {
return fmt.Errorf("%w: error building 'unmark primary bank account' query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("%w: error executing 'unmark primary bank account' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("%w: error getting rows affected for 'unmark primary bank account' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return fmt.Errorf("%w: error unmarking primary bank account: no rows affected", dberrors.ErrInternal)
}
return nil
}
func (c *client) DeleteBankAccount(
ctx context.Context,
request *dbtypes.BankAccountDeleteRequest,
) (*dbtypes.BankAccountDeleteResponse, error) {
return nil, dberrors.ErrUnimplemented
}

View File

@@ -0,0 +1,107 @@
package pgdb
import (
"context"
"crypto/tls"
"crypto/x509"
"database/sql"
"fmt"
"os"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
)
// ะ˜ะฝั‚ะตั€ั„ะตะนั ะฟะตั€ะตะดะฐะตั‚ัั ะฒ ะฝะตัะบัะฟะพั€ั‚ะธั€ัƒะตะผั‹ะต ั„ัƒะฝะบั†ะธะธ
// ะฝัƒะถะตะฝ ะดะปั ั‚ะพะณะพ, ั‡ั‚ะพะฑั‹ ะฝะต ะทะฐะฒะธัะธะปะพ ะพั‚ ั‚ะพะณะพ, ั‡ั‚ะพ ะฟะตั€ะตะดะฐั‘ะผ
// ั‚ั€ะฐะฝะทะฐั†ะธัŽ ะธะปะธ ะพะฑั‹ั‡ะฝะพะต ัะพะตะดะธะฝะตะฝะธะต
type Driver interface {
// QueryContext executes a query that returns rows, typically a SELECT.
// The args are for any placeholder parameters in the query.
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
// QueryRowContext executes a query that is expected to return at most one row.
// QueryRowContext always returns a non-nil value. Errors are deferred until
// [Row]'s Scan method is called.
// If the query selects no rows, the [*Row.Scan] will return [ErrNoRows].
// Otherwise, [*Row.Scan] scans the first selected row and discards
// the rest.
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
// ExecContext executes a query without returning any rows.
// The args are for any placeholder parameters in the query.
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
type PostgresConfig struct {
Host string
Port uint16
Username string
Password string
Database string
Schema string
SSLMode string
SSLRootCert string
}
type client struct {
config PostgresConfig
db *sqlx.DB
}
func NewClient(cfg PostgresConfig) (*client, error) {
rootCertPool := x509.NewCertPool()
caCert, err := os.ReadFile(cfg.SSLRootCert)
if err != nil {
return nil, fmt.Errorf("failed to read CA cert: %w", err)
}
if ok := rootCertPool.AppendCertsFromPEM(caCert); !ok {
return nil, fmt.Errorf("failed to append CA cert to pool")
}
tlsConfig := &tls.Config{
RootCAs: rootCertPool,
MinVersion: tls.VersionTLS12,
//nolint:gosec // TODO: set server name
InsecureSkipVerify: true,
}
config, err := pgx.ParseConfig("")
if err != nil {
return nil, fmt.Errorf("failed to parse [empty] config: %w", err)
}
config.Host = cfg.Host
config.Port = cfg.Port
config.Database = cfg.Database
config.User = cfg.Username
config.Password = cfg.Password
config.TLSConfig = tlsConfig
config.RuntimeParams = map[string]string{"sslmode": cfg.SSLMode}
db := sqlx.NewDb(stdlib.OpenDB(*config), "pgx")
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping postgres: %w", err)
}
return &client{
config: cfg,
db: db,
}, nil
}
func countOffset(page, pageSize uint64) uint64 {
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = DefaultPaginationPageSize
}
return (page - 1) * pageSize
}

View File

@@ -0,0 +1,506 @@
package pgdb
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/lib/pq"
)
func CompanyModerationStatusIdToString(status int32) dbtypes.CompanyModerationStatus {
switch status {
case 0:
return dbtypes.CompanyModerationStatusPending
case 1:
return dbtypes.CompanyModerationStatusApproved
case 2:
return dbtypes.CompanyModerationStatusRejected
case 3:
return dbtypes.CompanyModerationStatusNew
default:
return dbtypes.CompanyModerationStatusNew
}
}
func CompanyModerationStatusStringToId(status dbtypes.CompanyModerationStatus) int32 {
switch status {
case dbtypes.CompanyModerationStatusPending:
return 0
case dbtypes.CompanyModerationStatusApproved:
return 1
case dbtypes.CompanyModerationStatusRejected:
return 2
case dbtypes.CompanyModerationStatusNew:
return 3
default:
return 3
}
}
//nolint:gocognit // TODO: refactor
func (c *client) GetCompanyList(
ctx context.Context,
request *dbtypes.CompanyListGetRequest,
) (*dbtypes.CompanyListGetResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
companiesTable = fmt.Sprintf("%s.%s", c.config.Schema, CompaniesTableName)
)
getComList := psql.Select(
"id", "uid", "name", "legal_person", "description", "website",
"physical_address", "legal_address", "inn", "is_active", // TODO: add KPP when DB supports it
"has_moderation_ticket", "staff", "metadata", "additional_fields_tmpl",
).
From(companiesTable).
Where(squirrel.Eq{"uid": request.Id})
query, args, err := getComList.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get distributor company list query: %v", dberrors.ErrInternal, err)
}
rows, err := c.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing get distributor company list query: %v", dberrors.ErrInternal, err)
}
defer rows.Close()
var res dbtypes.CompanyListGetResponse
for rows.Next() {
var (
name, legalPerson, description, website, physicalAddress sql.NullString
legalAddress, inn, metadata, additionalFieldsTmpl sql.NullString
staff pq.StringArray
company dbtypes.Company
)
if err := rows.Scan(
&company.Id, &company.OwnerId, &name, &legalPerson, &description, &website,
&physicalAddress, &legalAddress, &inn, &company.IsActive,
&company.HasModerationTicket, &staff, &metadata, &additionalFieldsTmpl,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
if name.Valid {
company.Name = &name.String
}
if legalPerson.Valid {
company.LegalPerson = &legalPerson.String
}
if description.Valid {
company.Description = &description.String
}
if website.Valid {
company.Website = &website.String
}
if physicalAddress.Valid {
company.PhysicalAddress = &physicalAddress.String
}
if legalAddress.Valid {
company.LegalAddress = &legalAddress.String
}
if inn.Valid {
company.Inn = &inn.String
}
company.Staff = staff
if metadata.Valid {
company.Metadata = &metadata.String
}
if additionalFieldsTmpl.Valid {
company.ExtraFieldsTemplate = &additionalFieldsTmpl.String
}
res.Companies = append(res.Companies, company)
}
return &res, nil
}
func (c *client) GetCompanyById(
ctx context.Context,
request *dbtypes.CompanyByIdGetRequest,
) (*dbtypes.CompanyByIdGetResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
companiesTable = fmt.Sprintf("%s.%s", c.config.Schema, CompaniesTableName)
)
getComList := psql.Select(
"id", "uid", "name", "legal_person", "description", "website",
"physical_address", "legal_address", "inn", "is_active", // TODO: add KPP when DB supports it
"has_moderation_ticket", "staff", "metadata", "additional_fields_tmpl",
).
From(companiesTable).
Where(squirrel.And{
squirrel.Eq{"id": request.CompanyId},
squirrel.Eq{"uid": request.Id},
})
query, args, err := getComList.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get distributor company list query: %v", dberrors.ErrInternal, err)
}
row := c.db.QueryRowContext(ctx, query, args...)
var (
name, legalPerson, description, website, physicalAddress sql.NullString
legalAddress, inn, metadata, additionalFieldsTmpl sql.NullString
staff pq.StringArray
company dbtypes.Company
)
if err := row.Scan(
&company.Id, &company.OwnerId, &name, &legalPerson, &description, &website,
&physicalAddress, &legalAddress, &inn, &company.IsActive,
&company.HasModerationTicket, &staff, &metadata, &additionalFieldsTmpl,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
if name.Valid {
company.Name = &name.String
}
if legalPerson.Valid {
company.LegalPerson = &legalPerson.String
}
if description.Valid {
company.Description = &description.String
}
if website.Valid {
company.Website = &website.String
}
if physicalAddress.Valid {
company.PhysicalAddress = &physicalAddress.String
}
if legalAddress.Valid {
company.LegalAddress = &legalAddress.String
}
if inn.Valid {
company.Inn = &inn.String
}
company.Staff = staff
if metadata.Valid {
company.Metadata = &metadata.String
}
if additionalFieldsTmpl.Valid {
company.ExtraFieldsTemplate = &additionalFieldsTmpl.String
}
return &dbtypes.CompanyByIdGetResponse{Company: company}, nil
}
func (c *client) CreateCompany(
ctx context.Context,
request *dbtypes.CompanyCreateRequest,
) (*dbtypes.CompanyCreateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("%w: error starting transaction: %v", dberrors.ErrInternal, err)
}
defer func() { _ = tx.Rollback() }()
res, err := c.createCompany(ctx, tx, request)
if err != nil {
return nil, fmt.Errorf("error creating company: %w", err)
}
if err := c.createCompanyValidationTicket(ctx, tx, res.Id, request); err != nil {
return nil, fmt.Errorf("error creating company validation ticket: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("error committing transaction: %w", err)
}
return res, nil
}
func (c *client) createCompany(
ctx context.Context,
driver Driver,
request *dbtypes.CompanyCreateRequest,
) (*dbtypes.CompanyCreateResponse, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
companyTable = fmt.Sprintf("%s.%s", c.config.Schema, CompaniesTableName)
)
// TODO: use normal uuid after DB reengineering
comId := fmt.Sprintf("%sCOM", strings.ReplaceAll(uuid.NewString(), "-", ""))
createCompany := psql.Insert(companyTable).
Columns(
"id", "uid", "is_active", "has_moderation_ticket", "metadata", "additional_fields_tmpl",
).
Values(
comId, request.OwnerId, false, true, request.Metadata, request.ExtraFieldsTemplate,
)
query, args, err := createCompany.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building create company query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing create company query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for create company query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrInternal
}
return &dbtypes.CompanyCreateResponse{Id: comId}, nil
}
func (c *client) createCompanyValidationTicket(
ctx context.Context,
driver Driver,
companyId string,
request *dbtypes.CompanyCreateRequest,
) error {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
comValTable = fmt.Sprintf("%s.%s", c.config.Schema, CompanyValidationTicketsTableName)
)
var (
ticketId = fmt.Sprintf("%sTCK", strings.ReplaceAll(uuid.NewString(), "-", ""))
)
createCompany := psql.Insert(comValTable).
Columns(
"id", "company_id", "name", "legal_person", "description", "website",
"physical_address", "legal_address", "inn", // TODO: add KPP when DB supports it
"staff", "status",
).
Values(
ticketId, companyId, request.Name, request.LegalPerson, request.Description, request.Website,
request.PhysicalAddress, request.LegalAddress, request.Inn,
request.Staff, CompanyModerationStatusStringToId(dbtypes.CompanyModerationStatusPending), // TODO: switch to status "NEW"
)
query, args, err := createCompany.ToSql()
if err != nil {
return fmt.Errorf("%w: error building create company moderation ticket query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("%w: error executing create company moderation ticket query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("%w: error getting rows affected for create company moderation ticket query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return dberrors.ErrInternal
}
return nil
}
func (c *client) UpdateCompany(
ctx context.Context,
request *dbtypes.CompanyUpdateRequest,
) (*dbtypes.CompanyUpdateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("%w: error starting transaction: %v", dberrors.ErrInternal, err)
}
defer func() { _ = tx.Rollback() }()
res, err := c.updateCompany(ctx, tx, request)
if err != nil {
return nil, fmt.Errorf("error updating company: %w", err)
}
if err := c.updateCompanyValidationTicket(ctx, tx, request.Id, request); err != nil {
return nil, fmt.Errorf("error updating company validation ticket: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("error committing transaction: %w", err)
}
return res, nil
}
func (c *client) updateCompany(
ctx context.Context,
driver Driver,
request *dbtypes.CompanyUpdateRequest,
) (*dbtypes.CompanyUpdateResponse, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
companyTable = fmt.Sprintf("%s.%s", c.config.Schema, CompaniesTableName)
)
updateCompany := psql.Update(companyTable).
SetMap(map[string]any{
"is_active": false,
"has_moderation_ticket": true,
"metadata": request.Metadata,
"additional_fields_tmpl": request.ExtraFields,
}).
Where(squirrel.Eq{"id": request.Id})
query, args, err := updateCompany.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building update company query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing update company query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for update company query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrInternal
}
return &dbtypes.CompanyUpdateResponse{}, nil
}
// NOTE: do we believe that every company has a moderation ticket?
func (c *client) updateCompanyValidationTicket(
ctx context.Context,
driver Driver,
companyId string,
request *dbtypes.CompanyUpdateRequest,
) error {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
comValTable = fmt.Sprintf("%s.%s", c.config.Schema, CompanyValidationTicketsTableName)
)
updateCompany := psql.Update(comValTable).
Where(squirrel.Eq{"company_id": companyId})
if request.Name != nil {
updateCompany = updateCompany.Set("name", *request.Name)
}
if request.LegalPerson != nil {
updateCompany = updateCompany.Set("legal_person", *request.LegalPerson)
}
if request.Description != nil {
updateCompany = updateCompany.Set("description", *request.Description)
}
if request.Website != nil {
updateCompany = updateCompany.Set("website", *request.Website)
}
if request.PhysicalAddress != nil {
updateCompany = updateCompany.Set("physical_address", *request.PhysicalAddress)
}
if request.LegalAddress != nil {
updateCompany = updateCompany.Set("legal_address", *request.LegalAddress)
}
if request.Inn != nil {
updateCompany = updateCompany.Set("inn", *request.Inn)
}
if len(request.Staff) > 0 {
updateCompany = updateCompany.Set("staff", request.Staff)
}
query, args, err := updateCompany.ToSql()
if err != nil {
return fmt.Errorf("%w: error building update company moderation ticket query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("%w: error executing update company moderation ticket query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("%w: error getting rows affected for update company moderation ticket query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return dberrors.ErrInternal
}
return nil
}

View File

@@ -0,0 +1,23 @@
package pgdb
const (
BalancesTableName = "balances"
BankAccountsTableName = "company_bank_accounts"
CompaniesTableName = "company"
CompanyValidationTicketsTableName = "company_validation"
SubmissionsTableName = "submission"
TransactionsTableName = "transactions"
VacanciesTableName = "vacancy"
VacancyCandidatesTableName = "vacancy_candidates"
VacancyDescriptionHistoryTableName = "vacancy_moderation_descriptions"
UsersTableName = "client"
UserValidationTableName = "client_validation"
)
const (
DefaultPaginationPageSize = 20
)
const (
PGErrUniqueViolation = "23505"
)

View File

@@ -0,0 +1,128 @@
package pgdb
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"github.com/Masterminds/squirrel"
)
func (c *client) GetVacancyIntegrationInfoById(
ctx context.Context,
vacancyId string,
) (string, *dbtypes.VacancyExtraFieldsTemplate, error) {
return c.getVacancyIntegrationInfoById(ctx, c.db, vacancyId)
}
func (c *client) getVacancyIntegrationInfoById(
ctx context.Context,
driver Driver,
vacancyId string,
) (string, *dbtypes.VacancyExtraFieldsTemplate, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
vacancyTable = fmt.Sprintf("%s.%s", c.config.Schema, VacanciesTableName)
)
getCompanyIdQuery := psql.Select("company_id", "additional_fields").
From(vacancyTable).
Where(squirrel.Eq{"id": vacancyId})
query, args, err := getCompanyIdQuery.ToSql()
if err != nil {
return "", nil, fmt.Errorf("%w: error building query: %v", dberrors.ErrInternal, err)
}
row := driver.QueryRowContext(ctx, query, args...)
var (
companyId string
fieldsString sql.NullString
fields dbtypes.VacancyExtraFieldsTemplate
)
if err := row.Scan(&companyId, &fieldsString); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil, dberrors.ErrNotFound
}
return "", nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
if fieldsString.Valid {
if err := json.Unmarshal([]byte(fieldsString.String), &fields); err != nil {
return "", nil, fmt.Errorf("%w: error unmarshalling fields: %v", dberrors.ErrInternal, err)
}
}
return companyId, &fields, nil
}
func (c *client) GetCompanyMetadataById(
ctx context.Context,
companyId string,
) (*dbtypes.CompanyMetadata, *dbtypes.CompanyExtraFieldsTemplate, error) {
return c.getCompanyMetadataById(ctx, c.db, companyId)
}
func (c *client) getCompanyMetadataById(
ctx context.Context,
driver Driver,
companyId string,
) (*dbtypes.CompanyMetadata, *dbtypes.CompanyExtraFieldsTemplate, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
companyTable = fmt.Sprintf("%s.%s", c.config.Schema, CompaniesTableName)
)
getCompanyMetadataQuery := psql.Select(
"metadata",
"additional_fields_tmpl",
).
From(companyTable).
Where(squirrel.Eq{"id": companyId})
query, args, err := getCompanyMetadataQuery.ToSql()
if err != nil {
return nil, nil, fmt.Errorf("%w: error building query: %v", dberrors.ErrInternal, err)
}
row := driver.QueryRowContext(ctx, query, args...)
var (
metadataString sql.NullString
metadata dbtypes.CompanyMetadata
fieldsTemplateString sql.NullString
fieldsTemplate dbtypes.CompanyExtraFieldsTemplate
)
if err := row.Scan(&metadataString, &fieldsTemplateString); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, dberrors.ErrNotFound
}
return nil, nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
if metadataString.Valid {
if err := json.Unmarshal([]byte(metadataString.String), &metadata); err != nil {
return nil, nil, fmt.Errorf("%w: error unmarshalling metadata: %v", dberrors.ErrInternal, err)
}
}
if fieldsTemplateString.Valid {
if err := json.Unmarshal([]byte(fieldsTemplateString.String), &fieldsTemplate); err != nil {
return nil, nil, fmt.Errorf("%w: error unmarshalling fields template: %v", dberrors.ErrInternal, err)
}
}
return &metadata, &fieldsTemplate, nil
}

View File

@@ -0,0 +1,130 @@
package pgdb
import (
"context"
"database/sql"
"errors"
"fmt"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"github.com/Masterminds/squirrel"
"github.com/lib/pq"
)
func (c *client) GetProfileById(
ctx context.Context,
request *dbtypes.ProfileGetRequest,
) (*dbtypes.ProfileGetResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
usersTable = fmt.Sprintf("%s.%s", c.config.Schema, UsersTableName)
)
getProfile := psql.Select(
"uid", "name", "phone", "email",
).From(usersTable).
Where(squirrel.Eq{"uid": request.Id})
query, args, err := getProfile.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get profile query: %v", dberrors.ErrInternal, err)
}
row := c.db.QueryRowContext(ctx, query, args...)
var (
name, phoneNumber, email sql.NullString
profile dbtypes.Profile
)
if err := row.Scan(&profile.Id, &name, &phoneNumber, &email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
if name.Valid {
profile.Name = name.String
}
if phoneNumber.Valid {
profile.PhoneNumber = phoneNumber.String
}
if email.Valid {
profile.Email = email.String
}
return &dbtypes.ProfileGetResponse{
Profile: &profile,
}, nil
}
func (c *client) UpdateProfile(
ctx context.Context,
request *dbtypes.ProfileUpdateRequest,
) (*dbtypes.ProfileUpdateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
if request.Name == nil && request.PhoneNumber == nil && request.Email == nil {
return nil, fmt.Errorf("%w: nothing to update", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
usersTable = fmt.Sprintf("%s.%s", c.config.Schema, UsersTableName)
)
updateProfile := psql.Update(usersTable).
Where(squirrel.Eq{"uid": request.Id})
if request.Name != nil {
updateProfile = updateProfile.Set("name", *request.Name)
}
if request.PhoneNumber != nil {
updateProfile = updateProfile.Set("phone", *request.PhoneNumber)
}
if request.Email != nil {
updateProfile = updateProfile.Set("email", *request.Email)
}
query, args, err := updateProfile.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building update profile query: %v", dberrors.ErrInternal, err)
}
res, err := c.db.ExecContext(ctx, query, args...)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
if pqErr.Code == PGErrUniqueViolation {
return nil, dberrors.ErrConflict
}
}
return nil, fmt.Errorf("%w: error executing update profile query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for update profile query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrInternal
}
return &dbtypes.ProfileUpdateResponse{}, nil
}

View File

@@ -0,0 +1,494 @@
package pgdb
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
)
func SubmissionStatusIdToString(status int32) dbtypes.SubmissionStatus {
switch status {
case 0:
return dbtypes.SubStatusUnspecified
case 1:
return dbtypes.SubStatusNew
case 2:
return dbtypes.SubStatusPending
case 3:
return dbtypes.SubStatusOnInterview
case 4:
return dbtypes.SubStatusRejected
case 5:
return dbtypes.SubStatusCancelled
case 6:
return dbtypes.SubStatusApproved
default:
return dbtypes.SubStatusUnspecified
}
}
func SubmissionStatusStringToId(status dbtypes.SubmissionStatus) int32 {
switch status {
case dbtypes.SubStatusUnspecified:
return 0
case dbtypes.SubStatusNew:
return 1
case dbtypes.SubStatusPending:
return 2
case dbtypes.SubStatusOnInterview:
return 3
case dbtypes.SubStatusRejected:
return 4
case dbtypes.SubStatusCancelled:
return 5
case dbtypes.SubStatusApproved:
return 6
default:
return 0
}
}
//nolint:gocognit // not so hard
func (c *client) GetSubmissionList(
ctx context.Context,
request *dbtypes.SubmissionListGetRequest,
) (*dbtypes.SubmissionListGetResponse, error) {
if request == nil {
return nil, nil
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
submissionsTable = fmt.Sprintf("%s.%s", c.config.Schema, SubmissionsTableName)
)
getSubmissions := psql.Select(
"id", "uid", "vacancy_id", "submission_status_id",
"cv", "name", "phone", "email", "birthday", "info",
).From(submissionsTable).
Limit(request.PageSize).
Offset(countOffset(request.Page, request.PageSize))
if request.Filters != nil {
if request.Filters.AgentId != nil {
getSubmissions = getSubmissions.Where(squirrel.Eq{"uid": *request.Filters.AgentId})
}
if request.Filters.VacancyId != nil {
getSubmissions = getSubmissions.Where(squirrel.Eq{"vacancy_id": *request.Filters.VacancyId})
}
if request.Filters.Status != nil && *request.Filters.Status != dbtypes.SubStatusUnspecified {
getSubmissions = getSubmissions.Where(squirrel.Eq{"submission_status_id": SubmissionStatusStringToId(*request.Filters.Status)})
}
}
query, args, err := getSubmissions.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get submissions query: %v", dberrors.ErrInternal, err)
}
rows, err := c.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing get submissions query: %v", dberrors.ErrInternal, err)
}
defer rows.Close()
submissionsList := &dbtypes.SubmissionListGetResponse{
Submissions: make([]dbtypes.Submission, 0, request.PageSize),
}
for rows.Next() {
var (
cvLink, info sql.NullString
statusId int32
candidateName string
sub = dbtypes.Submission{
AgentInfo: new(dbtypes.AgentInfo),
VacancyInfo: new(dbtypes.VacancyInfo),
CandidateInfo: new(dbtypes.CandidateInfo),
}
)
if err := rows.Scan(
&sub.Id, &sub.AgentInfo.Id, &sub.VacancyInfo.Id, &statusId,
&cvLink, &candidateName, &sub.CandidateInfo.PhoneNumber, &sub.CandidateInfo.Email,
&sub.CandidateInfo.Birthday, &info,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
sub.CandidateInfo.LastName, sub.CandidateInfo.FirstName, sub.CandidateInfo.MiddleName = splitName(candidateName)
if cvLink.Valid {
sub.CandidateInfo.CvLink = &cvLink.String
}
if info.Valid {
sub.CandidateInfo.Resume = &info.String
}
sub.Status = SubmissionStatusIdToString(statusId)
submissionsList.Submissions = append(submissionsList.Submissions, sub)
}
return submissionsList, nil
}
func splitName(name string) (string, string, string) {
var (
parts = strings.SplitN(name, " ", 3)
lastName, firstName, middleName string
)
if len(parts) > 0 {
lastName = parts[0]
}
if len(parts) > 1 {
firstName = parts[1]
}
if len(parts) > 2 {
middleName = parts[2]
}
return lastName, firstName, middleName
}
func (c *client) CreateSubmission(
ctx context.Context,
request *dbtypes.SubmissionCreateRequest,
) (*dbtypes.SubmissionCreateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
if request.CandidateInfo == nil {
return nil, fmt.Errorf("%w: request.CandidateInfo is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
submissionsTable = fmt.Sprintf("%s.%s", c.config.Schema, SubmissionsTableName)
)
var (
// TODO: use normal uuid after DB reengineering
subId = fmt.Sprintf("%sSUB", strings.ReplaceAll(uuid.NewString(), "-", ""))
fullName = formatName(request.CandidateInfo.LastName, request.CandidateInfo.FirstName, request.CandidateInfo.MiddleName)
)
createSubmission := psql.Insert(submissionsTable).
Columns(
"id", "uid", "vacancy_id", "submission_status_id",
"cv", "name", "phone", "email", "birthday", "info",
).
Values(
subId, request.AgentId, request.VacancyId, SubmissionStatusStringToId(dbtypes.SubStatusNew),
request.CandidateInfo.CvLink, fullName, request.CandidateInfo.PhoneNumber,
request.CandidateInfo.Email, request.CandidateInfo.Birthday, request.CandidateInfo.Resume,
)
query, args, err := createSubmission.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building create submission query: %v", dberrors.ErrInternal, err)
}
res, err := c.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing create submission query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for create submission query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrInternal
}
return &dbtypes.SubmissionCreateResponse{
Id: subId,
}, nil
}
// NOTE: lastName and firstName are required to be non-empty
func formatName(lastName, firstName, middleName string) string {
name := fmt.Sprintf("%s %s", lastName, firstName)
if middleName != "" {
name = fmt.Sprintf("%s %s", name, middleName)
}
return name
}
//nolint:funlen,gocognit // TODO: refactor
func (c *client) UpdateSubmissionStatus(
ctx context.Context,
request *dbtypes.SubmissionStatusUpdateRequest,
) (*dbtypes.SubmissionStatusUpdateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
submissionsTable = fmt.Sprintf("%s.%s", c.config.Schema, SubmissionsTableName)
)
updateSubmissionStatus := psql.Update(submissionsTable).
Set("submission_status_id", SubmissionStatusStringToId(request.Status)).
Where(squirrel.Eq{"id": request.Id})
query, args, err := updateSubmissionStatus.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building 'update submission status' query: %v", dberrors.ErrInternal, err)
}
if request.Status != dbtypes.SubStatusApproved {
res, err := c.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing 'update submission status' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for 'update submission status' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrNotFound
}
return &dbtypes.SubmissionStatusUpdateResponse{}, nil
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("%w: error starting transaction: %v", dberrors.ErrInternal, err)
}
defer func() { _ = tx.Rollback() }()
vacancy, agentId, err := c.getSubmissionMetadata(ctx, tx, request.Id)
if err != nil {
return nil, fmt.Errorf("error getting vacancy info: %w", err)
}
if vacancy.AgentReward == nil {
return nil, fmt.Errorf("%w: agent reward is unknown", dberrors.ErrForbidden)
}
if vacancy.IsArchived {
return nil, fmt.Errorf("%w: vacancy is closed", dberrors.ErrForbidden)
}
if vacancy.CurrentCandidates+1 > vacancy.RequiredCandidates {
return nil, fmt.Errorf("%w: vacancy quota exceeded", dberrors.ErrForbidden)
}
if err := c.addSubmissionCandidate(ctx, tx, vacancy.Id); err != nil {
return nil, fmt.Errorf("%w: error adding submission candidate: %v", dberrors.ErrInternal, err)
}
payload := dbtypes.TransactionPayload{
Origin: "submission",
CompanyId: vacancy.Company.Id,
CompanyName: vacancy.Company.Name,
VacancyId: vacancy.Id,
VacancyName: vacancy.Name,
}
molvaAgentTransId := uuid.NewString()
if _, err := c.createTransactionWithDriver(ctx, tx, &dbtypes.TransactionCreateRequest{
OwnerId: agentId,
Amount: int64(*vacancy.AgentReward),
Currency: "RUB",
RequestId: molvaAgentTransId,
Payload: &payload,
}); err != nil {
return nil, fmt.Errorf("error creating molva -> agent transaction: %w", err)
}
distMolvaTransId := uuid.NewString()
if _, err := c.createTransactionWithDriver(ctx, tx, &dbtypes.TransactionCreateRequest{
OwnerId: vacancy.Company.Id,
Amount: -int64(*vacancy.AgentReward),
Currency: "RUB",
RequestId: distMolvaTransId,
Payload: &payload,
}); err != nil {
return nil, fmt.Errorf("error creating dist -> molva transaction: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("%w: error committing transaction: %w", dberrors.ErrInternal, err)
}
res, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing 'update submission status' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for 'update submission status' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrNotFound
}
return &dbtypes.SubmissionStatusUpdateResponse{}, nil
}
func (c *client) getSubmissionMetadata(
ctx context.Context,
driver Driver,
submissionId string,
) (*dbtypes.Vacancy, string, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
submissionsTable = fmt.Sprintf("%s.%s s", c.config.Schema, SubmissionsTableName)
companiesTable = fmt.Sprintf("%s.%s c", c.config.Schema, CompaniesTableName)
vacanciesTable = fmt.Sprintf("%s.%s v", c.config.Schema, VacanciesTableName)
vacancyCandidatesTable = fmt.Sprintf("%s.%s vc", c.config.Schema, VacancyCandidatesTableName)
)
getMetadata := psql.Select(
"s.uid", "v.id", "v.name", "v.company_id", "c.name", "v.agent_reward", "v.is_archived",
"vc.current_candidates_amt", "vc.required_candidates_amt",
).From(submissionsTable).
InnerJoin(fmt.Sprintf("%s on s.vacancy_id = v.id", vacanciesTable)).
InnerJoin(fmt.Sprintf("%s on v.id = vc.vacancy_id", vacancyCandidatesTable)).
InnerJoin(fmt.Sprintf("%s on v.company_id = c.id", companiesTable)).
Where(squirrel.Eq{"s.id": submissionId}).
Suffix("FOR UPDATE OF vc")
query, args, err := getMetadata.ToSql()
if err != nil {
return nil, "", fmt.Errorf("%w: error building 'get submission metadata' query: %v", dberrors.ErrInternal, err)
}
row := driver.QueryRowContext(ctx, query, args...)
var (
agentId string
vacancy dbtypes.Vacancy
)
if err := row.Scan(
&agentId,
&vacancy.Id, &vacancy.Name, &vacancy.Company.Id, &vacancy.Company.Name,
&vacancy.AgentReward, &vacancy.IsArchived,
&vacancy.CurrentCandidates, &vacancy.RequiredCandidates,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, "", dberrors.ErrNotFound
}
return nil, "", fmt.Errorf("%w: error scanning row for 'get submission metadata' query: %v", dberrors.ErrInternal, err)
}
return &vacancy, agentId, nil
}
func (c *client) addSubmissionCandidate(
ctx context.Context,
driver Driver,
vacancyId string,
) error {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
vacancyCandisTable = fmt.Sprintf("%s.%s", c.config.Schema, VacancyCandidatesTableName)
)
addSubmissionCandidate := psql.Update(vacancyCandisTable).
Set("current_candidates_amt", squirrel.Expr("current_candidates_amt + 1")).
Where(squirrel.Eq{"vacancy_id": vacancyId})
query, args, err := addSubmissionCandidate.ToSql()
if err != nil {
return fmt.Errorf("%w: error building 'add submission candidate' query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("%w: error executing 'add submission candidate' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("%w: error getting rows affected for 'add submission candidate' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return dberrors.ErrInternal
}
return nil
}
func (c *client) DeleteSubmission(
ctx context.Context,
request *dbtypes.SubmissionDeleteRequest,
) (*dbtypes.SubmissionDeleteResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
submissionsTable = fmt.Sprintf("%s.%s", c.config.Schema, SubmissionsTableName)
)
deleteSubmission := psql.Update(submissionsTable).
Set("submission_status_id", SubmissionStatusStringToId(dbtypes.SubStatusCancelled)).
Where(squirrel.Eq{"id": request.Id})
query, args, err := deleteSubmission.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building delete submission query: %v", dberrors.ErrInternal, err)
}
res, err := c.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing delete submission query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for delete submission query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrNotFound
}
return &dbtypes.SubmissionDeleteResponse{}, nil
}

View File

@@ -0,0 +1,485 @@
package pgdb
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"github.com/Masterminds/squirrel"
)
func getTransactionTypeByAmount(amount int64) dbtypes.TransactionType {
if amount > 0 {
return dbtypes.TransactionTypeDeposit
}
return dbtypes.TransactionTypeWithdrawal
}
// TODO: add migration to rebind statuses
func TransactionStatusIdToString(status int32) dbtypes.TransactionStatus {
switch status {
case 0:
return dbtypes.TransactionStatusPending
case 1:
return dbtypes.TransactionStatusApproved
case 2:
return dbtypes.TransactionStatusRejected
case 3:
return dbtypes.TransactionStatusNew
default:
return dbtypes.TransactionStatusPending
}
}
func TransactionStatusStringToId(status dbtypes.TransactionStatus) int32 {
switch status {
case dbtypes.TransactionStatusNew:
return 3
case dbtypes.TransactionStatusPending:
return 0
case dbtypes.TransactionStatusApproved:
return 1
case dbtypes.TransactionStatusRejected:
return 2
default:
return 3
}
}
//nolint:gocognit // not so hard
func (c *client) GetTransactionList(
ctx context.Context,
request *dbtypes.TransactionListGetRequest,
) (*dbtypes.TransactionListGetResponse, error) {
if request == nil {
return nil, nil
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
transactionsTable = fmt.Sprintf("%s.%s", c.config.Schema, TransactionsTableName)
)
getTransactions := psql.Select(
"id", "owner_id", "bank_account_id", "amount", "currency", "status", "created_at", "payload",
).From(transactionsTable).
Where(squirrel.Eq{"owner_id": request.OwnerId}).
Limit(request.PageSize).
Offset(countOffset(request.Page, request.PageSize))
getTransactions, err := c.setGetTransactionsQueryFilters(getTransactions, request.Filters)
if err != nil {
return nil, fmt.Errorf("%w: error setting get transactions query filters: %v", dberrors.ErrBadRequest, err)
}
query, args, err := getTransactions.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get transactions query: %v", dberrors.ErrInternal, err)
}
rows, err := c.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing get transactions query: %v", dberrors.ErrInternal, err)
}
defer rows.Close()
transList := &dbtypes.TransactionListGetResponse{
Transactions: make([]dbtypes.Transaction, 0, request.PageSize),
}
for rows.Next() {
var (
transStatus int32
ownerId string
payload, bankAccountId sql.NullString
transaction dbtypes.Transaction
)
if err := rows.Scan(
&transaction.Id, &ownerId, &bankAccountId, &transaction.Amount, &transaction.Currency,
&transStatus, &transaction.CreatedAt, &payload,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
if bankAccountId.Valid {
bankAccountInfo, err := c.getBankAccountInfoById(ctx, c.db, bankAccountId.String)
if err != nil {
return nil, fmt.Errorf("%w: error getting bank account info: %v", dberrors.ErrInternal, err)
}
transaction.BankAccountInfo = bankAccountInfo
ownerInfo, err := c.getOwnerInfoById(ctx, c.db, ownerId, bankAccountInfo.OwnerType)
if err != nil {
return nil, fmt.Errorf("%w: error getting owner info: %v", dberrors.ErrInternal, err)
}
transaction.OwnerInfo = ownerInfo
}
transaction.Type = getTransactionTypeByAmount(transaction.Amount)
transaction.Status = TransactionStatusIdToString(transStatus)
if payload.Valid {
var payloadData dbtypes.TransactionPayload
if err := json.Unmarshal([]byte(payload.String), &payloadData); err != nil {
return nil, fmt.Errorf("%w: error unmarshaling transaction payload: %v", dberrors.ErrInternal, err)
}
transaction.Payload = &payloadData
}
transList.Transactions = append(transList.Transactions, transaction)
}
return transList, nil
}
func (c *client) setGetTransactionsQueryFilters(
query squirrel.SelectBuilder,
filters *dbtypes.TransactionListFilters,
) (squirrel.SelectBuilder, error) {
if filters == nil {
return query, nil
}
if filters.Type != nil {
switch *filters.Type {
case dbtypes.TransactionTypeDeposit:
query = query.Where(squirrel.Gt{
"amount": 0,
})
case dbtypes.TransactionTypeWithdrawal:
query = query.Where(squirrel.Lt{
"amount": 0,
})
default:
return query, fmt.Errorf("%w: invalid transaction type: %v", dberrors.ErrBadRequest, *filters.Type)
}
}
if filters.Status != nil {
query = query.Where(squirrel.Eq{"status": TransactionStatusStringToId(*filters.Status)})
}
if filters.BankAccountId != nil {
query = query.Where(squirrel.Eq{"bank_account_id": *filters.BankAccountId})
}
return query, nil
}
func (c *client) getOwnerInfoById(
ctx context.Context,
driver Driver,
ownerId string,
ownerType string,
) (*dbtypes.TransactionOwnerInfo, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
clientTableName = fmt.Sprintf("%s.%s", c.config.Schema, UsersTableName)
companyTableName = fmt.Sprintf("%s.%s", c.config.Schema, CompaniesTableName)
)
var getOwnerInfoById squirrel.SelectBuilder
// TODO: reingeneer the DB
switch ownerType {
case "agent":
getOwnerInfoById = psql.Select(
"uid",
"name",
).
From(clientTableName).
Where(squirrel.Eq{"uid": ownerId})
case "company":
getOwnerInfoById = psql.Select(
"id",
"name",
).
From(companyTableName).
Where(squirrel.Eq{"id": ownerId})
default:
return nil, fmt.Errorf("%w: invalid owner type", dberrors.ErrBadRequest)
}
query, args, err := getOwnerInfoById.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get owner info by id query: %v", dberrors.ErrInternal, err)
}
row := driver.QueryRowContext(ctx, query, args...)
var ownerInfo dbtypes.TransactionOwnerInfo
if err := row.Scan(
&ownerInfo.Id,
&ownerInfo.Name,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row for get owner info by id query: %v", dberrors.ErrInternal, err)
}
return &ownerInfo, nil
}
func (c *client) getBankAccountInfoById(
ctx context.Context,
driver Driver,
bankAccountId string,
) (*dbtypes.BankAccountInfo, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
bankAccountsTableName = fmt.Sprintf("%s.%s", c.config.Schema, BankAccountsTableName)
)
getBankAccountInfoById := psql.Select(
"id",
"account_number",
"bank_name",
"bik",
"correspondent_account",
"owner_type",
).
From(bankAccountsTableName).
Where(squirrel.Eq{"id": bankAccountId})
query, args, err := getBankAccountInfoById.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get bank account info by id query: %v", dberrors.ErrInternal, err)
}
row := driver.QueryRowContext(ctx, query, args...)
var accountInfo dbtypes.BankAccountInfo
if err := row.Scan(
&accountInfo.Id,
&accountInfo.AccountNumber,
&accountInfo.BankName,
&accountInfo.Bik,
&accountInfo.CorrespondentAccount,
&accountInfo.OwnerType,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row for get bank account info by id query: %v", dberrors.ErrInternal, err)
}
return &accountInfo, nil
}
func (c *client) CreateTransaction(
ctx context.Context,
request *dbtypes.TransactionCreateRequest,
) (*dbtypes.TransactionCreateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("%w: error starting transaction: %v", dberrors.ErrInternal, err)
}
defer func() { _ = tx.Rollback() }()
result, err := c.createTransactionWithDriver(ctx, tx, request)
if err != nil {
return nil, fmt.Errorf("error creating transaction: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("%w: error committing transaction: %w", dberrors.ErrInternal, err)
}
return result, nil
}
func (c *client) createTransactionWithDriver(
ctx context.Context,
driver Driver,
request *dbtypes.TransactionCreateRequest,
) (*dbtypes.TransactionCreateResponse, error) {
if _, err := c.getRawBalanceForUpdate(ctx, driver, request.OwnerId); err != nil {
return nil, fmt.Errorf("error getting raw balance for update: %w", err)
}
result, err := c.createTransaction(ctx, driver, request)
if err != nil {
if errors.Is(err, dberrors.ErrConflict) {
return result, nil
}
return nil, fmt.Errorf("error creating transaction: %w", err)
}
if err := c.updateBalance(ctx, driver, request.Amount, request.OwnerId); err != nil {
return nil, fmt.Errorf("error updating balance: %w", err)
}
return result, nil
}
func (c *client) getRawBalanceForUpdate(
ctx context.Context,
driver Driver,
ownerId string,
) (int64, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
balancesTable = fmt.Sprintf("%s.%s", c.config.Schema, BalancesTableName)
)
getBalance := psql.Select(
"raw_balance",
).From(balancesTable).
Where(squirrel.Eq{"owner_id": ownerId}).
Suffix("FOR UPDATE")
query, args, err := getBalance.ToSql()
if err != nil {
return 0, fmt.Errorf("%w: error building 'get balance' query: %v", dberrors.ErrInternal, err)
}
row := driver.QueryRowContext(ctx, query, args...)
var rawBalance int64
if err := row.Scan(&rawBalance); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, dberrors.ErrNotFound
}
return 0, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
return rawBalance, nil
}
func (c *client) updateBalance(
ctx context.Context,
driver Driver,
amountDelta int64,
ownerId string,
) error {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
balancesTable = fmt.Sprintf("%s.%s", c.config.Schema, BalancesTableName)
)
updateBalance := psql.Update(balancesTable).
SetMap(map[string]any{
"raw_balance": squirrel.Expr("raw_balance + ?", amountDelta),
"updated_at": squirrel.Expr("NOW()"),
}).
Where(squirrel.Eq{"owner_id": ownerId})
query, args, err := updateBalance.ToSql()
if err != nil {
return fmt.Errorf("%w: error building 'update balance' query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("%w: error executing 'update balance' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("%w: error getting rows affected for 'update balance' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return dberrors.ErrInternal
}
return nil
}
func (c *client) createTransaction(
ctx context.Context,
driver Driver,
request *dbtypes.TransactionCreateRequest,
) (*dbtypes.TransactionCreateResponse, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
transactionsTable = fmt.Sprintf("%s.%s", c.config.Schema, TransactionsTableName)
)
var payload []byte
if request.Payload == nil {
payload = []byte("{}")
} else {
payloadBytes, err := json.Marshal(request.Payload)
if err != nil {
return nil, fmt.Errorf("%w: error marshaling transaction payload: %v", dberrors.ErrInternal, err)
}
payload = payloadBytes
}
createTransaction := psql.Insert(transactionsTable).
Columns(
"id", "owner_id", "bank_account_id", "amount", "currency", "status", "created_at", "payload",
).
Values(
request.RequestId, request.OwnerId, request.BankAccountId, request.Amount, request.Currency,
dbtypes.TransactionStatusNew, squirrel.Expr("CURRENT_TIMESTAMP"), payload,
).
Suffix("ON CONFLICT (id, owner_id) DO NOTHING")
query, args, err := createTransaction.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building 'create transaction' query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing 'create transaction' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for 'create transaction' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return &dbtypes.TransactionCreateResponse{
Id: request.RequestId,
}, dberrors.ErrConflict
}
return &dbtypes.TransactionCreateResponse{
Id: request.RequestId,
}, nil
}

View File

@@ -0,0 +1,203 @@
package pgdb
import (
"context"
"database/sql"
"fmt"
"strings"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
)
func (c *client) GetClientValidation(
ctx context.Context,
request *dbtypes.ClientValidationGetRequest,
) (*dbtypes.ClientValidationGetResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
userValidationTable = fmt.Sprintf("%s.%s", c.config.Schema, UserValidationTableName)
)
getUserValidation := psql.Select(
"status", "description",
).
From(userValidationTable).
Where(squirrel.Eq{"uid": request.UserId})
query, args, err := getUserValidation.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building 'get user validation' query: %v", dberrors.ErrInternal, err)
}
row := c.db.QueryRowContext(ctx, query, args...)
var result dbtypes.ClientValidation
if err := row.Scan(&result.Status, &result.Description); err != nil {
if err == sql.ErrNoRows {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row for 'get user validation' query: %v", dberrors.ErrInternal, err)
}
return &dbtypes.ClientValidationGetResponse{
ClientValidation: &result,
}, nil
}
func (c *client) CreateUser(
ctx context.Context,
request *dbtypes.UserSaveRequest,
) (*dbtypes.UserSaveResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("%w: error starting transaction: %v", dberrors.ErrInternal, err)
}
defer func() { _ = tx.Rollback() }()
result, err := c.createUser(ctx, tx, request)
if err != nil {
return nil, fmt.Errorf("error creating user: %w", err)
}
if err := c.createUserValidationTicket(ctx, tx, request); err != nil {
return nil, fmt.Errorf("error creating user validation ticket: %w", err)
}
resp, err := c.createCompany(ctx, tx, &dbtypes.CompanyCreateRequest{
OwnerId: request.Id,
Staff: []string{
request.Id,
},
})
if err != nil {
return nil, fmt.Errorf("error creating company: %w", err)
}
var ownerId string
switch request.Type {
case dbtypes.UserTypeAgent:
ownerId = request.Id
case dbtypes.UserTypeDistributor:
ownerId = resp.Id
default:
return nil, fmt.Errorf("%w: unknown user type: %v", dberrors.ErrBadRequest, request.Type)
}
balanceId := fmt.Sprintf("%sBAL", strings.ReplaceAll(uuid.NewString(), "-", ""))
if err := c.createBalance(ctx, tx, &dbtypes.BalanceCreateRequest{
Id: balanceId,
OwnerId: ownerId,
RawBalance: 0,
CleanBalance: 0,
}); err != nil {
return nil, fmt.Errorf("error creating balance: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("%w: error committing transaction: %w", dberrors.ErrInternal, err)
}
return result, nil
}
func (c *client) createUser(
ctx context.Context,
driver Driver,
request *dbtypes.UserSaveRequest,
) (*dbtypes.UserSaveResponse, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
userTable = fmt.Sprintf("%s.%s", c.config.Schema, UsersTableName)
)
saveUser := psql.Insert(userTable).
Columns(
"uid", "name", "phone", "email", "client_type_id",
).
Values(
request.Id, request.FullName, request.Phone, request.Email, request.Type,
)
query, args, err := saveUser.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building 'save user' query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing 'save user' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for 'save user' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrInternal
}
return &dbtypes.UserSaveResponse{}, nil
}
func (c *client) createUserValidationTicket(
ctx context.Context,
driver Driver,
request *dbtypes.UserSaveRequest,
) error {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
userValidationTable = fmt.Sprintf("%s.%s", c.config.Schema, UserValidationTableName)
)
// TODO: use normal uuid after DB reengineering
ticketId := fmt.Sprintf("%sVAL", strings.ReplaceAll(uuid.NewString(), "-", ""))
createUserValidation := psql.Insert(userValidationTable).
Columns(
"id", "uid", "status", "description", "last_update",
).
Values(
ticketId, request.Id, dbtypes.ClientValStatusNew, request.FullName, squirrel.Expr("CURRENT_TIMESTAMP"),
)
query, args, err := createUserValidation.ToSql()
if err != nil {
return fmt.Errorf("%w: error building 'create user validation' query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("%w: error executing 'create user validation' query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("%w: error getting rows affected for 'create user validation' query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return dberrors.ErrInternal
}
return nil
}

View File

@@ -0,0 +1,573 @@
package pgdb
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
)
func VacancyStatusIdToString(status int32) dbtypes.VacancyStatus {
switch status {
case 0:
return dbtypes.VacUnspecified
case 1:
return dbtypes.VacNew
case 2:
return dbtypes.VacPending
case 3:
return dbtypes.VacApproved
case 4:
return dbtypes.VacRejected
default:
return dbtypes.VacUnspecified
}
}
func VacancyStatusStringToId(status dbtypes.VacancyStatus) int32 {
switch status {
case dbtypes.VacUnspecified:
return 0
case dbtypes.VacNew:
return 1
case dbtypes.VacPending:
return 2
case dbtypes.VacApproved:
return 3
case dbtypes.VacRejected:
return 4
default:
return 0
}
}
//nolint:funlen,gocognit // maybe refactor later
func (c *client) GetVacancyList(
ctx context.Context,
request *dbtypes.VacancyListGetRequest,
) (*dbtypes.VacancyListGetResponse, error) {
if request == nil {
return nil, nil
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
vacancyTable = fmt.Sprintf("%s.%s v", c.config.Schema, VacanciesTableName)
companyTable = fmt.Sprintf("%s.%s c", c.config.Schema, CompaniesTableName)
)
getVacList := psql.Select(
"v.id", "v.company_id", "c.name", "v.name", "v.address", "v.work_format",
"v.agent_reward", "v.region", "v.salary_top", "v.salary_bottom", "v.requirements",
"v.responsibilities", "v.additional_info", "v.is_archived",
"v.target_action", "v.target_action_amount", "v.publish_date",
"v.moderation_status_id", "v.required_candidates", "v.current_candidates",
"v.additional_fields",
).
From(vacancyTable).
LeftJoin(fmt.Sprintf("%s on v.company_id = c.id", companyTable)).
Limit(request.PageSize).
Offset(countOffset(request.Page, request.PageSize))
getVacList = c.setVacancyListFilters(getVacList, request.Filters)
query, args, err := getVacList.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building get vacancy list query: %v", dberrors.ErrInternal, err)
}
rows, err := c.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing get vacancy list query: %v", dberrors.ErrInternal, err)
}
defer rows.Close()
var (
res dbtypes.VacancyListGetResponse
vacancyIds = make([]string, 0, request.PageSize)
)
for rows.Next() {
var (
status int32
agentReward sql.NullInt32
comName, reqs, resps, extraInfo, extraFields sql.NullString
vacancy dbtypes.Vacancy
)
if err := rows.Scan(
&vacancy.Id, &vacancy.Company.Id, &comName, &vacancy.Name, &vacancy.Address,
&vacancy.WorkFormat, &agentReward, &vacancy.Region, &vacancy.SalaryTop, &vacancy.SalaryBottom,
&reqs, &resps, &extraInfo, &vacancy.IsArchived, &vacancy.TargetAction.Action, &vacancy.TargetAction.Duration,
&vacancy.CreatedAt, &status, &vacancy.RequiredCandidates, &vacancy.CurrentCandidates,
&extraFields,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row: %v", dberrors.ErrInternal, err)
}
if agentReward.Valid {
vacancy.AgentReward = &agentReward.Int32
}
if comName.Valid {
vacancy.Company.Name = comName.String
}
if reqs.Valid {
vacancy.Requirements = &reqs.String
}
if resps.Valid {
vacancy.Responsibilities = &resps.String
}
if extraInfo.Valid {
vacancy.ExtraInfo = &extraInfo.String
}
if extraFields.Valid {
vacancy.ExtraFields = &extraFields.String
}
vacancy.Moderation.Status = VacancyStatusIdToString(status)
res.Vacancies = append(res.Vacancies, vacancy)
vacancyIds = append(vacancyIds, vacancy.Id)
}
descriptions, err := c.getVacancyModerationDescriptionHistory(ctx, c.db, vacancyIds)
if err != nil {
if errors.Is(err, dberrors.ErrNotFound) {
return &res, nil
}
return nil, fmt.Errorf("error getting descriptions for vacancies: %w", err)
}
for i, vacancy := range res.Vacancies {
res.Vacancies[i].Moderation.DescriptionHistory = descriptions[vacancy.Id]
}
return &res, nil
}
func (c *client) setVacancyListFilters(
query squirrel.SelectBuilder,
filters *dbtypes.VacancyListFilters,
) squirrel.SelectBuilder {
if filters.DistributorId != nil {
query = query.Where(squirrel.Eq{"c.uid": *filters.DistributorId})
}
if filters.CompanyId != nil {
query = query.Where(squirrel.Eq{"v.company_id": *filters.CompanyId})
}
if filters.VacancyId != nil {
query = query.Where(squirrel.Eq{"v.id": *filters.VacancyId})
}
if filters.Region != nil {
query = query.Where(squirrel.Eq{"v.region": *filters.Region})
}
if filters.SalaryBottom != nil {
query = query.Where(squirrel.Or{
squirrel.GtOrEq{"v.salary_bottom": *filters.SalaryBottom},
squirrel.Eq{"v.salary_bottom": nil},
})
}
if filters.SalaryTop != nil {
query = query.Where(squirrel.Or{
squirrel.LtOrEq{"v.salary_top": *filters.SalaryTop},
squirrel.Eq{"v.salary_top": nil},
})
}
if filters.IsArchived != nil {
query = query.Where(squirrel.Eq{"v.is_archived": *filters.IsArchived})
}
if filters.Status != nil {
query = query.Where(squirrel.Eq{"v.moderation_status_id": VacancyStatusStringToId(*filters.Status)})
}
return query
}
func (c *client) getVacancyModerationDescriptionHistory(
ctx context.Context,
driver Driver,
vacancyIds []string,
) (map[string][]dbtypes.VacancyModerationDescription, error) {
if len(vacancyIds) == 0 {
return nil, nil
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
vacancyModerDescriptionsTable = fmt.Sprintf("%s.%s", c.config.Schema, VacancyDescriptionHistoryTableName)
)
getVacancyModerationDescriptionHistory := psql.Select(
"vacancy_id",
"description",
"created_at",
).
From(vacancyModerDescriptionsTable).
Where(squirrel.Eq{"vacancy_id": vacancyIds}).
OrderBy("vacancy_id", "created_at ASC")
query, args, err := getVacancyModerationDescriptionHistory.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building getVacancyModerationDescriptionHistory query: %v", dberrors.ErrInternal, err)
}
rows, err := driver.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing getVacancyModerationDescriptionHistory query: %v", dberrors.ErrInternal, err)
}
defer rows.Close()
descriptionsMap := make(map[string][]dbtypes.VacancyModerationDescription)
for rows.Next() {
var (
vacancyId string
description dbtypes.VacancyModerationDescription
)
if err := rows.Scan(
&vacancyId,
&description.Description,
&description.CreatedAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, dberrors.ErrNotFound
}
return nil, fmt.Errorf("%w: error scanning row for getVacancyModerationDescriptionHistory: %v", dberrors.ErrInternal, err)
}
descriptionsMap[vacancyId] = append(descriptionsMap[vacancyId], description)
}
return descriptionsMap, nil
}
func (c *client) CreateVacancy(
ctx context.Context,
request *dbtypes.VacancyCreateRequest,
) (*dbtypes.VacancyCreateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
vacancyTable = fmt.Sprintf("%s.%s", c.config.Schema, VacanciesTableName)
)
// TODO: use normal uuid after DB reengineering
vacancyId := fmt.Sprintf("%sVAC", strings.ReplaceAll(uuid.NewString(), "-", ""))
createVacancy := psql.Insert(vacancyTable).
Columns(
"id", "company_id", "name", "address", "work_format", "agent_reward",
"salary_top", "salary_bottom", "requirements", "responsibilities", "additional_info",
"region", "is_archived", "target_action", "target_action_amount", "moderation_status_id",
"required_candidates", "publish_date", "current_candidates", "additional_fields",
).
Values(
vacancyId, request.CompanyId, request.Name, request.Address, request.WorkFormat, request.AgentReward,
request.SalaryTop, request.SalaryBottom, request.Requirements, request.Responsibilities, request.ExtraInfo,
request.Region, false, request.TargetAction.Action, request.TargetAction.Duration, VacancyStatusStringToId(dbtypes.VacNew),
request.RequiredCandidates, squirrel.Expr("now()"), request.CurrentCandidates, request.ExtraFields,
)
query, args, err := createVacancy.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building create vacancy query: %v", dberrors.ErrInternal, err)
}
res, err := c.db.ExecContext(ctx, query, args...)
if err != nil {
// TODO: process conflict via err.(*pq.Error).Code
return nil, fmt.Errorf("%w: error executing create vacancy query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for create vacancy query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrInternal
}
return &dbtypes.VacancyCreateResponse{Id: vacancyId}, nil
}
func (c *client) UpdateVacancy(
ctx context.Context,
request *dbtypes.VacancyUpdateRequest,
) (*dbtypes.VacancyUpdateResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("%w: error starting transaction: %v", dberrors.ErrInternal, err)
}
defer func() { _ = tx.Rollback() }()
status, err := c.getVacancyModerStatusForUpdate(ctx, tx, request.Id)
if err != nil {
return nil, fmt.Errorf("error getting vacancy moder status: %w", err)
}
if status == dbtypes.VacApproved {
return nil, dberrors.ErrForbidden
}
res, err := c.updateVacancy(ctx, tx, request)
if err != nil {
return nil, fmt.Errorf("error updating vacancy: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("error committing transaction: %w", err)
}
return res, nil
}
func (c *client) getVacancyModerStatusForUpdate(
ctx context.Context,
driver Driver,
vacancyId string,
) (dbtypes.VacancyStatus, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
vacancyTable = fmt.Sprintf("%s.%s", c.config.Schema, VacanciesTableName)
)
getVacancyModerStatus := psql.Select(
"moderation_status_id",
).
From(vacancyTable).
Where(squirrel.Eq{"id": vacancyId}).
Suffix("FOR UPDATE")
query, args, err := getVacancyModerStatus.ToSql()
if err != nil {
return "", fmt.Errorf("%w: error building get vacancy moder status query: %v", dberrors.ErrInternal, err)
}
row := driver.QueryRowContext(ctx, query, args...)
var (
statusId int32
)
if err := row.Scan(&statusId); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", dberrors.ErrNotFound
}
return "", fmt.Errorf("%w: error scanning row for get vacancy moder status query: %v", dberrors.ErrInternal, err)
}
return VacancyStatusIdToString(statusId), nil
}
func (c *client) updateVacancy(
ctx context.Context,
driver Driver,
request *dbtypes.VacancyUpdateRequest,
) (*dbtypes.VacancyUpdateResponse, error) {
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
vacancyTable = fmt.Sprintf("%s.%s", c.config.Schema, VacanciesTableName)
)
updateVacancy := psql.Update(vacancyTable).
Where(squirrel.Eq{"id": request.Id})
if request.Name != nil {
updateVacancy = updateVacancy.Set("name", *request.Name)
}
if request.Address != nil {
updateVacancy = updateVacancy.Set("address", *request.Address)
}
if request.WorkFormat != nil {
updateVacancy = updateVacancy.Set("work_format", *request.WorkFormat)
}
if request.AgentReward != nil {
updateVacancy = updateVacancy.Set("agent_reward", *request.AgentReward)
}
if request.SalaryTop != nil {
updateVacancy = updateVacancy.Set("salary_top", *request.SalaryTop)
}
if request.SalaryBottom != nil {
updateVacancy = updateVacancy.Set("salary_bottom", *request.SalaryBottom)
}
if request.Requirements != nil {
updateVacancy = updateVacancy.Set("requirements", *request.Requirements)
}
if request.Responsibilities != nil {
updateVacancy = updateVacancy.Set("responsibilities", *request.Responsibilities)
}
if request.ExtraInfo != nil {
updateVacancy = updateVacancy.Set("extra_info", *request.ExtraInfo)
}
if request.Region != nil {
updateVacancy = updateVacancy.Set("region", *request.Region)
}
if request.TargetAction.Action != nil {
updateVacancy = updateVacancy.Set("target_action", *request.TargetAction.Action)
}
if request.TargetAction.Duration != nil {
updateVacancy = updateVacancy.Set("target_action_amount", *request.TargetAction.Duration)
}
if request.RequiredCandidates != nil {
updateVacancy = updateVacancy.Set("required_candidates", *request.RequiredCandidates)
}
if request.ExtraFields != nil {
updateVacancy = updateVacancy.Set("additional_fields", *request.ExtraFields)
}
query, args, err := updateVacancy.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building update vacancy query: %v", dberrors.ErrInternal, err)
}
res, err := driver.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing update vacancy query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for update vacancy query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrInternal
}
return &dbtypes.VacancyUpdateResponse{}, nil
}
func (c *client) DeleteVacancy(
ctx context.Context,
request *dbtypes.VacancyDeleteRequest,
) (*dbtypes.VacancyDeleteResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
vacancyTable = fmt.Sprintf("%s.%s", c.config.Schema, VacanciesTableName)
)
deleteVacancy := psql.Update(vacancyTable).
Set("is_archived", true).
Where(squirrel.Eq{"id": request.Id})
query, args, err := deleteVacancy.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building delete vacancy query: %v", dberrors.ErrInternal, err)
}
res, err := c.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing delete vacancy query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for delete vacancy query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrNotFound
}
return &dbtypes.VacancyDeleteResponse{}, nil
}
func (c *client) SendVacancyToModeration(
ctx context.Context,
request *dbtypes.SendVacancyToModerationRequest,
) (*dbtypes.SendVacancyToModerationResponse, error) {
if request == nil {
return nil, fmt.Errorf("%w: request is nil", dberrors.ErrBadRequest)
}
var (
psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
vacancyTable = fmt.Sprintf("%s.%s", c.config.Schema, VacanciesTableName)
)
sendVacancyToModeration := psql.Update(vacancyTable).
Set("moderation_status_id", VacancyStatusStringToId(dbtypes.VacPending)).
Where(squirrel.Eq{"id": request.Id})
query, args, err := sendVacancyToModeration.ToSql()
if err != nil {
return nil, fmt.Errorf("%w: error building send vacancy to moderation query: %v", dberrors.ErrInternal, err)
}
res, err := c.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: error executing send vacancy to moderation query: %v", dberrors.ErrInternal, err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("%w: error getting rows affected for send vacancy to moderation query: %v", dberrors.ErrInternal, err)
}
if rowsAffected == 0 {
return nil, dberrors.ErrNotFound
}
return &dbtypes.SendVacancyToModerationResponse{}, nil
}

View File

@@ -0,0 +1,217 @@
package dbtypes
import "time"
type (
Balance struct {
RawBalance int64
CleanBalance int64
}
)
type (
BalanceGetRequest struct {
OwnerId string
}
BalanceGetResponse struct {
Balance *Balance
}
)
type (
BalanceCreateRequest struct {
Id string
OwnerId string
RawBalance int64
CleanBalance int64
}
)
type TransactionStatus string
const (
TransactionStatusNew TransactionStatus = "new"
TransactionStatusPending TransactionStatus = "pending"
TransactionStatusApproved TransactionStatus = "approved"
TransactionStatusRejected TransactionStatus = "rejected"
)
func NewTransactionStatus(status *string) *TransactionStatus {
if status == nil {
return nil
}
res := TransactionStatus(*status)
return &res
}
type TransactionType string
const (
TransactionTypeDeposit TransactionType = "deposit"
TransactionTypeWithdrawal TransactionType = "withdrawal"
)
func NewTransactionType(typ *string) *TransactionType {
if typ == nil {
return nil
}
res := TransactionType(*typ)
return &res
}
type (
Transaction struct {
Id string
OwnerInfo *TransactionOwnerInfo
BankAccountInfo *BankAccountInfo
Type TransactionType
Status TransactionStatus
Amount int64
Currency string
CreatedAt time.Time
Payload *TransactionPayload
}
BankAccountInfo struct {
Id string
AccountNumber string
AccountName string
BankName string
Bik string
CorrespondentAccount string
OwnerType string
}
TransactionOwnerInfo struct {
Id string
Name string
}
// TODO: make DB migration to use this
// TransactionPayload struct {
// Origin string `json:"origin"`
// CompanyInfo *PayloadCompanyInfo `json:"company_info"`
// VacancyInfo *PayloadVacancyInfo `json:"vacancy_info"`
// }
// PayloadCompanyInfo struct {
// Id string `json:"id"`
// Name string `json:"name"`
// }
// PayloadVacancyInfo struct {
// Id string `json:"id"`
// Name string `json:"name"`
// }
TransactionPayload struct {
Origin string `json:"origin"`
CompanyId string `json:"company_id"`
CompanyName string `json:"company_name"`
VacancyId string `json:"vacancy_id"`
VacancyName string `json:"vacancy_name"`
}
)
type (
TransactionListGetRequest struct {
OwnerId string
Filters *TransactionListFilters
Page uint64
PageSize uint64
}
TransactionListFilters struct {
Type *TransactionType
Status *TransactionStatus
BankAccountId *string
}
TransactionListGetResponse struct {
Transactions []Transaction
}
)
type (
TransactionCreateRequest struct {
OwnerId string
Amount int64
Currency string
BankAccountId *string
RequestId string
Payload *TransactionPayload
}
TransactionCreateResponse struct {
Id string
}
)
type (
BankAccount struct {
Id string
OwnerId string
AccountNumber string
AccountName string
BankName string
Bik string
CorrespondentAccount string
IsPrimary bool
CreatedAt time.Time
UpdatedAt time.Time
}
)
type (
BankAccountListGetRequest struct {
OwnerId string
}
BankAccountListGetResponse struct {
BankAccounts []BankAccount
}
)
type (
BankAccountCreateRequest struct {
OwnerId string
AccountNumber *string
AccountName *string
BankName *string
Bik *string
CorrespondentAccount *string
IsPrimary *bool
}
BankAccountCreateResponse struct {
Id string
}
)
type (
BankAccountUpdateRequest struct {
Id string
AccountNumber *string
AccountName *string
BankName *string
Bik *string
CorrespondentAccount *string
IsPrimary *bool
}
BankAccountUpdateResponse struct{}
)
type (
BankAccountDeleteRequest struct {
Id string
OwnerId string
}
BankAccountDeleteResponse struct{}
)

View File

@@ -0,0 +1,101 @@
package dbtypes
type CompanyModerationStatus string
const (
CompanyModerationStatusNew CompanyModerationStatus = "new"
CompanyModerationStatusPending CompanyModerationStatus = "pending"
CompanyModerationStatusApproved CompanyModerationStatus = "approved"
CompanyModerationStatusRejected CompanyModerationStatus = "rejected"
)
type (
Company struct {
Id string
OwnerId string
Name *string
LegalPerson *string
Description *string
Website *string
PhysicalAddress *string
LegalAddress *string
Inn *string
Kpp *string
IsActive bool
HasModerationTicket bool
Staff []string
Metadata *string
ExtraFieldsTemplate *string
}
)
type (
CompanyListGetRequest struct {
Id string
}
CompanyListGetResponse struct {
Companies []Company
}
)
type (
CompanyByIdGetRequest struct {
Id string
CompanyId string
}
CompanyByIdGetResponse struct {
Company Company
}
)
type (
CompanyCreateRequest struct {
OwnerId string
Name *string
LegalPerson *string
Description *string
Website *string
PhysicalAddress *string
LegalAddress *string
Inn *string
Kpp *string
Staff []string
Metadata *string
ExtraFieldsTemplate *string
}
CompanyCreateResponse struct {
Id string
}
)
type (
CompanyUpdateRequest struct {
Id string
Name *string
LegalPerson *string
Description *string
Website *string
PhysicalAddress *string
LegalAddress *string
Inn *string // can be updated in several rare cases
Kpp *string // can be updated in several rare cases
Staff []string
Metadata *string
ExtraFields *string
}
CompanyUpdateResponse struct {
}
)
type (
CompanyDeleteRequest struct {
Id string
}
CompanyDeleteResponse struct {
}
)

View File

@@ -0,0 +1,53 @@
package dbtypes
type Fieldtype string
type (
CompanyMetadata map[string]string
ValidValue struct {
DisplayName string `json:"display_name"`
Value string `json:"value"`
}
/*
CompanyExtraFieldTemplate describes single
additional field of company additional fields template
*/
CompanyExtraFieldTemplate struct {
SystemName string `json:"system_name"`
DisplayName string `json:"display_name"`
Type Fieldtype `json:"type"`
Required bool `json:"required"`
MultipleChoice bool `json:"multiple_choice"`
ValidValues []ValidValue `json:"valid_values"`
}
/*
CompanyExtraFieldsTemplate describes additional fields
needed to integrate company
*/
CompanyExtraFieldsTemplate struct {
Fields []CompanyExtraFieldTemplate `json:"fields"`
}
/*
VacancyExtraFieldTemplate describes single filled
additional field of vacancy additional fields template
*/
VacancyExtraFieldTemplate struct {
SystemName string `json:"system_name"`
Type Fieldtype `json:"type"`
IsValidValue bool `json:"is_valid_values"`
MultipleChoice bool `json:"multiple_choice"`
Value []string `json:"value"`
}
/*
VacancyExtraFieldsTemplate describes additional fields
filled during vacancy creation
*/
VacancyExtraFieldsTemplate struct {
Fields []VacancyExtraFieldTemplate `json:"fields"`
}
)

View File

@@ -0,0 +1,31 @@
package dbtypes
type (
Profile struct {
Id string
Name string
PhoneNumber string
Email string
}
)
type (
ProfileGetRequest struct {
Id string
}
ProfileGetResponse struct {
Profile *Profile
}
)
type (
ProfileUpdateRequest struct {
Id string
Name *string
PhoneNumber *string
Email *string
}
ProfileUpdateResponse struct{}
)

View File

@@ -0,0 +1,100 @@
package dbtypes
type SubmissionStatus string
const (
SubStatusUnspecified SubmissionStatus = "unspecified"
SubStatusNew SubmissionStatus = "new"
SubStatusPending SubmissionStatus = "pending"
SubStatusOnInterview SubmissionStatus = "on_interview"
SubStatusApproved SubmissionStatus = "approved"
SubStatusCancelled SubmissionStatus = "cancelled"
SubStatusRejected SubmissionStatus = "rejected"
)
func NewSubmissionStatus(status *string) *SubmissionStatus {
if status == nil {
return nil
}
res := SubmissionStatus(*status)
return &res
}
type (
Submission struct {
Id string
AgentInfo *AgentInfo
VacancyInfo *VacancyInfo
CandidateInfo *CandidateInfo
Status SubmissionStatus
}
AgentInfo struct {
Id string
}
VacancyInfo struct {
Id string
}
CandidateInfo struct {
Id string
FirstName string
LastName string
MiddleName string
PhoneNumber string
Email string
Birthday string
CvLink *string
Resume *string
}
)
type (
SubmissionListGetRequest struct {
Filters *SubmissionListGetFilters
Page uint64
PageSize uint64
}
SubmissionListGetFilters struct {
AgentId *string
VacancyId *string
Status *SubmissionStatus
}
SubmissionListGetResponse struct {
Submissions []Submission
}
)
type (
SubmissionCreateRequest struct {
AgentId string
VacancyId string
CandidateInfo *CandidateInfo
}
SubmissionCreateResponse struct {
Id string
}
)
type (
SubmissionStatusUpdateRequest struct {
Id string
Status SubmissionStatus
}
SubmissionStatusUpdateResponse struct{}
)
type (
SubmissionDeleteRequest struct {
Id string
}
SubmissionDeleteResponse struct{}
)

View File

@@ -0,0 +1,57 @@
package dbtypes
type ClientValidationStatus string
const (
ClientValStatusNew ClientValidationStatus = "new"
ClientValStatusPending ClientValidationStatus = "on review"
ClientValStatusApproved ClientValidationStatus = "confirmed"
ClientValStatusRejected ClientValidationStatus = "rejected"
)
type (
ClientValidation struct {
Status ClientValidationStatus `json:"status"`
Description string `json:"description"`
}
)
type (
ClientValidationGetRequest struct {
UserId string
}
ClientValidationGetResponse struct {
ClientValidation *ClientValidation `json:"moderation"`
}
)
type UserType int32
const (
UserTypeAgent UserType = 0 // NOTE: no iota here
UserTypeDistributor UserType = 1
)
type (
User struct {
Id string
FullName string
Phone string
Email string
Type UserType
}
)
type (
UserSaveRequest struct {
Id string
FullName string
Phone string
Email string
Type UserType
}
UserSaveResponse struct {
}
)

View File

@@ -0,0 +1,184 @@
package dbtypes
import "time"
type VacancyStatus string
const (
VacUnspecified VacancyStatus = "unspecified"
VacNew VacancyStatus = "new"
VacPending VacancyStatus = "pending"
VacApproved VacancyStatus = "approved"
VacRejected VacancyStatus = "rejected"
)
func NewVacancyStatus(status *string) *VacancyStatus {
if status == nil {
return nil
}
res := VacancyStatus(*status)
return &res
}
// <=============================>
// Common Vacancy types
// <=============================>
type (
Vacancy struct {
Id string
Company VacancyCompanyInfo
Name string
Address string
WorkFormat string
AgentReward *int32
SalaryTop int32
SalaryBottom int32
Requirements *string
Responsibilities *string
ExtraInfo *string
Region string
IsArchived bool
TargetAction VacancyTargetAction
Moderation VacancyModeration
RequiredCandidates int32
CurrentCandidates int32
ExtraFields *string
CreatedAt time.Time
}
VacancyCompanyInfo struct {
Id string
Name string
LogoLink *string
}
VacancyTargetAction struct {
Action string
Duration int32
}
VacancyModeration struct {
Status VacancyStatus
DescriptionHistory []VacancyModerationDescription
}
VacancyModerationDescription struct {
Description *string
CreatedAt time.Time
}
)
// <=============================>
// Get Vacancy List
// <=============================>
type (
VacancyListGetRequest struct {
Filters *VacancyListFilters
Page uint64
PageSize uint64
}
VacancyListFilters struct {
DistributorId *string
CompanyId *string
VacancyId *string
Region *string
SalaryBottom *int32
SalaryTop *int32
IsArchived *bool
Status *VacancyStatus
}
VacancyListGetResponse struct {
Vacancies []Vacancy
}
)
// <=============================>
// Create Vacancy
// <=============================>
type (
VacancyCreateRequest struct {
CompanyId string
Name string
Address string
WorkFormat string
AgentReward *int32
SalaryTop int32
SalaryBottom int32
Requirements *string
Responsibilities *string
ExtraInfo *string
Region string
TargetAction VacancyTargetAction
RequiredCandidates int32
CurrentCandidates int32
ExtraFields *string
}
VacancyCreateResponse struct {
Id string
}
)
// <=============================>
// Update Vacancy
// <=============================>
type (
VacancyUpdateRequest struct {
Id string
Name *string
Address *string
WorkFormat *string
AgentReward *int32
SalaryTop *int32
SalaryBottom *int32
Requirements *string
Responsibilities *string
ExtraInfo *string
Region *string
TargetAction VacancyTargetActionForUpdate
RequiredCandidates *int32
ExtraFields *string
}
VacancyTargetActionForUpdate struct {
Action *string
Duration *int32
}
VacancyUpdateResponse struct {
}
)
// <=============================>
// Delete Vacancy
// <=============================>
type (
VacancyDeleteRequest struct {
Id string
}
VacancyDeleteResponse struct {
}
)
// <=============================>
// Send Vacancy to Moderation
// <=============================>
type (
SendVacancyToModerationRequest struct {
Id string
}
SendVacancyToModerationResponse struct {
}
)

17
internal/feed/errors.go Normal file
View File

@@ -0,0 +1,17 @@
package feed
import "errors"
// ะžัˆะธะฑะบะธ, ัะฒัะทะฐะฝะฝั‹ะต ั ะฒะฐะปะธะดะฐั†ะธะตะน ะฒั…ะพะดะฝั‹ั… ะดะฐะฝะฝั‹ั…
var (
// ErrCancellationReasonRequired ะ’ะพะทะฒั€ะฐั‰ะฐะตั‚ัั ะฟั€ะธ ะฟะพะฟั‹ั‚ะบะต ะพั‚ะผะตะฝั‹ ัะพะฑั‹ั‚ะธั ะฑะตะท ัƒะบะฐะทะฐะฝะธั ะฟั€ะธั‡ะธะฝั‹
ErrCancellationReasonRequired = errors.New("cancellation reason is required for feed event cancellation")
// ErrAttachmentIDRequired ะ’ะพะทะฒั€ะฐั‰ะฐะตั‚ัั ะฟั€ะธ ะฟะพะฟั‹ั‚ะบะต ะพั‚ะผะตะฝั‹ ัะพะฑั‹ั‚ะธั ะฑะตะท ัƒะบะฐะทะฐะฝะธั Id ะฒะปะพะถะตะฝะธั
ErrAttachmentIDRequired = errors.New("attachment Id is required for feed event cancellation")
// ErrUserIDRequired ะ’ะพะทะฒั€ะฐั‰ะฐะตั‚ัั ะฟั€ะธ ะฟะพะฟั‹ั‚ะบะต ะพั‚ะผะตะฝั‹ ัะพะฑั‹ั‚ะธั ะฑะตะท ัƒะบะฐะทะฐะฝะธั Id ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั
ErrUserIDRequired = errors.New("user Id cancellation is required for feed event cancellation")
// ErrCreationInvalidData ะ’ะพะทะฒั€ะฐั‰ะฐะตั‚ัั ะฟั€ะธ ะฟะพะฟั‹ั‚ะบะต ัะพะทะดะฐะฝะธั ัะพะฑั‹ั‚ะธั ะฑะตะท ัƒะบะฐะทะฐะฝะธั Id ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั, ะธะปะธ ั‚ะธะฟะฐ ัะพะฑั‹ั‚ะธั, ะธะปะธ ัะพะพะฑั‰ะตะฝะธั
ErrCreationInvalidData = errors.New("invalid event data provided for creation (missing owner, type, or message)")
ErrNotFound = errors.New("not found")
)

9
internal/feed/filters.go Normal file
View File

@@ -0,0 +1,9 @@
package feed
type Filter struct {
OwnerID string // Id ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั
EventTypes []EventType // ะคะธะปัŒั‚ั€ ะฟะพ ั‚ะธะฟะฐะผ ัะพะฑั‹ั‚ะธะน
Limit uint64 // ะ›ะธะผะธั‚ ะทะฐะฟะธัะตะน
Offset uint64 // ะกะผะตั‰ะตะฝะธะต
ShowCancelled bool // ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ะพั‚ะผะตะฝะตะฝะฝั‹ะต
}

101
internal/feed/handlers.go Normal file
View File

@@ -0,0 +1,101 @@
package feed
import (
"context"
"fmt"
"log/slog"
)
type Handler struct {
Service *Service
logger *slog.Logger
}
func NewFeedHandler(logger *slog.Logger, service *Service) *Handler {
return &Handler{
Service: service,
logger: logger,
}
}
func (h *Handler) CreateEvent(ctx context.Context, event *Event) error {
if event == nil {
h.logger.Error("CreateEvent: empty event",
slog.String("error", ErrCreationInvalidData.Error()))
return ErrCreationInvalidData
}
h.logger.Debug("CreateEvent: Attempting to create event",
slog.String("type", event.EventType.String()),
slog.String("ownerID", event.OwnerId))
if event.OwnerId == "" || event.EventType == "" || event.Message == "" {
h.logger.Error("CreateEvent: Validation error",
slog.String("error", ErrCreationInvalidData.Error()),
slog.String("type", event.EventType.String()),
slog.String("ownerID", event.OwnerId),
slog.String("message", event.Message),
)
return ErrCreationInvalidData
}
if err := h.Service.AddUserEvent(ctx, event); err != nil {
h.logger.Error("CreateEvent: Service error",
slog.String("error", err.Error()),
slog.String("type", event.EventType.String()),
slog.String("ownerID", event.OwnerId))
return fmt.Errorf("Error creating event: %w", err)
}
h.logger.Debug("CreateEvent: Successfully created event",
slog.String("eventID", event.Id),
slog.String("type", event.EventType.String()),
slog.String("ownerID", event.OwnerId))
return nil
}
func (h *Handler) CancelEvent(ctx context.Context, attachmentId string, reason string) error {
h.logger.Debug("CancelEvent: Attempting to cancel event",
slog.String("attachmentID", attachmentId),
)
if attachmentId == "" {
h.logger.Error("CancelEvent: Validation error",
slog.String("error", "attachment Id is required"))
return ErrAttachmentIDRequired
}
if reason == "" {
h.logger.Error("CancelEvent: Validation error",
slog.String("error", "cancellation reason is required"),
slog.String("attachmentID", attachmentId))
return ErrCancellationReasonRequired
}
if err := h.Service.CancelEvents(ctx, attachmentId, reason); err != nil {
h.logger.Error("CancelEvent: Service error",
slog.String("error", err.Error()),
slog.String("attachmentID", attachmentId))
return fmt.Errorf("error cancelling event: %w", err)
}
h.logger.Debug("CancelEvent: Successfully cancelled event",
slog.String("attachmentID", attachmentId))
return nil
}
func (h *Handler) GetCompanyIdByUidTmp(ctx context.Context, ownerID string) ([]string, error) {
return h.Service.GetCompanyIdsByUid(ctx, ownerID)
}
func (h *Handler) GetAgentIdBySubmissionId(ctx context.Context, submissionId string) (string, error) {
return h.Service.GetAgentIdBySubmissionId(ctx, submissionId)
}

91
internal/feed/models.go Normal file
View File

@@ -0,0 +1,91 @@
package feed
import (
"database/sql"
"time"
)
type (
EventType string
UserRole string
Visibility string
)
const (
// User Roles
RoleAgent UserRole = "agent"
RoleDistributor UserRole = "distributor"
// Visibility Types
VisibilityPublic Visibility = "public" // ะŸะพะบะฐะทั‹ะฒะฐะตั‚ัั ะฒัะตะผ ะฐะณะตะฝั‚ะฐะผ(ะธะฝะธั†ะธะฐั‚ะพั€ ะผะพะถะตั‚ ะฑั‹ั‚ัŒ ั‚ะพะปัŒะบะพ ะดะธัั‚ั€)
VisibilityPrivate Visibility = "private" // ะŸะพะบะฐะทั‹ะฒะฐะตั‚ัั ั‚ะพะปัŒะบะพ ะธะฝะธั†ะธะฐั‚ะพั€ัƒ ัะพะฑั‹ั‚ะธั
VisibilityCompanyWide Visibility = "company_wide" // ะŸะพะบะฐะทั‹ะฒะฐะตั‚ัั ะฒัะตะผ ะฒ ะบะพะผะฟะฐะฝะธะธ
// Event Types (Common)
EventWelcome EventType = "welcome"
EventNewCompanyMember EventType = "new_company_member"
// Users Events
EventProfileChanged EventType = "profile_changed"
EventCompanyCreated EventType = "company_created"
EventCompanyChanged EventType = "company_changed"
EventVacancyCreated EventType = "vacancy_created"
EventVacancyChanged EventType = "vacancy_changed"
EventVacancyModerationSent EventType = "vacancy_moderation_sent"
EventSubmissionStatusChanged EventType = "submission_status_changed"
EventTransactionCreated EventType = "transaction_created"
EventBankAccountChanged EventType = "bank_details_changed"
EventBankAccountCreated EventType = "bank_account_created"
EventPostAnketa EventType = "post_anketa"
)
type Event struct {
Id string `json:"id" db:"id"`
OwnerId string `json:"owner_id" db:"owner_id"`
OwnerType UserRole `json:"owner_type" db:"owner_type"`
Message string `json:"message" db:"message"`
Visibility Visibility `json:"visibility" db:"visibility"`
CompanyID *string `json:"company_id,omitempty"`
EventType EventType `json:"event_type" db:"event_type"`
Payload EventPayload `json:"payload" db:"payload"`
IsCancelled bool `json:"is_cancelled" db:"is_cancelled"`
CancellationReason *string `json:"cancellation_reason" db:"cancellation_reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
func (e EventType) String() string {
return string(e)
}
func (u UserRole) String() string {
return string(u)
}
func (e *Event) processNullableFields(companyID, cancellationReason sql.NullString) {
if companyID.Valid {
e.CompanyID = &companyID.String
}
if cancellationReason.Valid {
e.CancellationReason = &cancellationReason.String
}
}
type EventPayload struct {
AttachmentId string `json:"attachment_id" db:"attachment_id"`
AttachmentType AttachmentType `json:"attachment_type" db:"attachment_type"`
AdditionalReceiver string `json:"additional_receiver,omitempty" db:"additional_receiver"` // may be uid or company id
CustomData string `json:"custom_data,omitempty"`
}
type AttachmentType string
const (
AttachmentTypeVacancy AttachmentType = "vacancy"
AttachmentTypeCV AttachmentType = "cv"
AttachmentTypeProfile AttachmentType = "profile"
AttachmentTypeCompany AttachmentType = "company"
AttachmentTypeBankAccount AttachmentType = "bank_account"
AttachmentTypeSubmission AttachmentType = "submission"
)

325
internal/feed/service.go Normal file
View File

@@ -0,0 +1,325 @@
package feed
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/constants"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database"
sq "github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/lib/pq"
)
type Service struct {
db *sql.DB
schemaName string
logger *slog.Logger
dbClient database.Client //nolint:unused // TODO: ะฟะตั€ะตะฟะธัะฐั‚ัŒ ะฝะฐ ัั‚ะพะณะพ ะบะปะธะตะฝั‚ะฐ ะฒ ั€ะฐะผะบะฐั… https://tracker.yandex.ru/MOLVARAZRABOTKA-363
}
func NewService(dbUrl, schemaName string, logger *slog.Logger, dbClient database.Client) (*Service, error) {
db, err := sql.Open("postgres", dbUrl)
if err != nil {
return nil, fmt.Errorf("error opening database connection: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("error pinging database: %w", err)
}
return &Service{
db: db,
schemaName: schemaName,
logger: logger,
}, nil
}
func (s *Service) GetUserEvents(ctx context.Context, filter Filter, userType string) ([]Event, error) {
companyIDs, err := s.GetCompanyIdsByUid(ctx, filter.OwnerID)
if err != nil {
return nil, err
}
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
baseQuery := psql.Select(
"id", "owner_id", "owner_type", "message", "event_type", "visibility", "company_id",
"payload", "is_cancelled", "cancellation_reason", "created_at", "updated_at",
).From(fmt.Sprintf("%s.%s", pq.QuoteIdentifier(s.schemaName), constants.FeedEventsTableName))
conditions := []sq.Sqlizer{
sq.Eq{"owner_id": filter.OwnerID},
sq.And{
sq.Eq{"visibility": VisibilityCompanyWide},
sq.Expr("company_id = ANY(?)", pq.Array(companyIDs)),
},
sq.And{
sq.Expr("payload->>'additional_receiver' IS NOT NULL"),
sq.Or{
sq.Expr("(payload->>'additional_receiver')::uuid = ?::uuid", filter.OwnerID),
sq.Expr("(payload->>'additional_receiver')::text = ANY(?)", pq.Array(companyIDs)),
},
},
}
if userType == RoleAgent.String() {
conditions = append(conditions, sq.Eq{"visibility": VisibilityPublic})
}
query := baseQuery.Where(sq.Or(conditions))
if len(filter.EventTypes) > 0 {
query = query.Where(sq.Expr("event_type = ANY(?)", pq.Array(filter.EventTypes)))
}
if !filter.ShowCancelled {
query = query.Where(sq.Eq{"is_cancelled": false})
}
query = query.OrderBy("created_at DESC").
Limit(filter.Limit).
Offset(filter.Offset)
sqlQuery, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
s.logger.Debug("Executing GetUserEvents query", "query", sqlQuery, "args", args)
rows, err := s.db.QueryContext(ctx, sqlQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to query events: %w", err)
}
defer rows.Close()
var events []Event
for rows.Next() {
event, err := s.scanEventRow(rows)
if err != nil {
return nil, err
}
events = append(events, event)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating event rows: %w", err)
}
s.logger.Debug("GetUserEvents completed successfully",
slog.Int("count", len(events)),
slog.String("owner_id", filter.OwnerID),
slog.String("user_type", userType),
slog.String("company_ids", fmt.Sprintf("%v", companyIDs)),
slog.Any("filter", filter),
)
return events, nil
}
func (s *Service) GetCompanyIdsByUid(ctx context.Context, ownerID string) ([]string, error) {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.Select("company_id").
From(fmt.Sprintf("%s.%s", pq.QuoteIdentifier(s.schemaName), constants.ClientTableName)).
Where(sq.Eq{"uid": ownerID})
sqlQuery, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var companyID sql.NullString
if err = s.db.QueryRowContext(ctx, sqlQuery, args...).Scan(&companyID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return []string{}, nil
}
return nil, fmt.Errorf("failed to get company Id: %w", err)
}
if companyID.Valid {
return []string{companyID.String}, nil
}
return []string{}, nil
}
func (s *Service) scanEventRow(rows *sql.Rows) (Event, error) {
var (
event Event
payload []byte
companyIDScan sql.NullString
cancellationReasonScan sql.NullString
)
if err := rows.Scan(
&event.Id,
&event.OwnerId,
&event.OwnerType,
&event.Message,
&event.EventType,
&event.Visibility,
&companyIDScan,
&payload,
&event.IsCancelled,
&cancellationReasonScan,
&event.CreatedAt,
&event.UpdatedAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Event{}, nil
}
return Event{}, fmt.Errorf("failed to scan event: %w", err)
}
event.processNullableFields(companyIDScan, cancellationReasonScan)
event.processPayload(payload)
return event, nil
}
func (e *Event) processPayload(payload []byte) {
if len(payload) == 0 {
e.Payload = EventPayload{}
return
}
if err := json.Unmarshal(payload, &e.Payload); err != nil {
e.Payload = EventPayload{}
}
}
func (s *Service) AddUserEvent(ctx context.Context, event *Event) error {
if event == nil {
return ErrCreationInvalidData
}
if event.Id == "" {
event.Id = "MSG_" + uuid.New().String()
}
now := time.Now()
event.CreatedAt = now
event.UpdatedAt = now
payloadBytes, err := json.Marshal(event.Payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
var payloadArg interface{} = payloadBytes
if string(payloadBytes) == "null" || string(payloadBytes) == "{}" {
payloadArg = nil
}
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.Insert(fmt.Sprintf("%s.%s", pq.QuoteIdentifier(s.schemaName), constants.FeedEventsTableName)).
Columns(
"id", "owner_id", "owner_type", "message", "event_type", "visibility", "company_id",
"payload", "is_cancelled", "cancellation_reason", "created_at", "updated_at",
).
Values(
event.Id,
event.OwnerId,
event.OwnerType,
event.Message,
event.EventType,
event.Visibility,
event.CompanyID,
payloadArg,
event.IsCancelled,
event.CancellationReason,
event.CreatedAt,
event.UpdatedAt,
)
sqlQuery, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = s.db.ExecContext(ctx, sqlQuery, args...)
if err != nil {
return fmt.Errorf("failed to insert event: %w", err)
}
s.logger.Debug("Event added successfully", "event_id", event.Id)
return nil
}
func (s *Service) CancelEvents(ctx context.Context, attachmentID, cancellationReason string) error {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.Update(fmt.Sprintf("%s.%s", pq.QuoteIdentifier(s.schemaName), constants.FeedEventsTableName)).
Set("is_cancelled", true).
Set("cancellation_reason", cancellationReason).
Set("updated_at", sq.Expr("NOW()")).
Where(sq.Expr("payload ->> 'attachment_id' = ?", attachmentID)).
Where(sq.Eq{"is_cancelled": false})
sqlQuery, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
result, err := s.db.ExecContext(ctx, sqlQuery, args...)
if err != nil {
return fmt.Errorf("failed to execute cancel events query for attachmentID %s: %w", attachmentID, err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows for attachmentID %s: %w", attachmentID, err)
}
if rowsAffected == 0 {
s.logger.Debug("No active events found or updated for cancellation", "attachment_id", attachmentID)
} else {
s.logger.Debug("Events cancelled successfully", "attachment_id", attachmentID, "count", rowsAffected)
}
return nil
}
func (s *Service) GetAgentIdBySubmissionId(ctx context.Context, submissionId string) (string, error) {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.Select("uid").
From(fmt.Sprintf("%s.%s", pq.QuoteIdentifier(s.schemaName), constants.SubmissionTableName)).
Where(sq.Eq{"id": submissionId})
sqlQuery, args, err := query.ToSql()
if err != nil {
return "", fmt.Errorf("failed to build query: %w", err)
}
var agentId string
if err = s.db.QueryRowContext(ctx, sqlQuery, args...).Scan(&agentId); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", fmt.Errorf("failed to get agent Id: %w", err)
}
if agentId == "" {
s.logger.Debug("Agent Id not found", "submission_id", submissionId)
return "", ErrNotFound
}
return agentId, nil
}

View File

@@ -0,0 +1,15 @@
package filemanager
import "errors"
var (
ErrInvalidFileType = errors.New("invalid file type")
ErrParameterNotFound = errors.New("parameter required, but not found")
ErrFileNotFound = errors.New("file not found")
ErrFileAlreadyExists = errors.New("file already exists")
ErrFailedUploadFile = errors.New("failed to upload file to S3")
ErrFailedToCacheUrl = errors.New("failed to cache URL")
ErrMsgReadFile = errors.New("failed to read file")
ErrMsgGetPresignedURL = errors.New("failed to get presigned URL")
)

View File

@@ -0,0 +1,127 @@
package storage
import (
"context"
"io"
"mime/multipart"
"os"
"path/filepath"
filemanager "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/file_manager"
)
type Storage struct {
basePath string
}
func New(basePath string) *Storage {
return &Storage{
basePath: basePath,
}
}
func (s *Storage) GetFilePaths(
ctx context.Context,
fileType filemanager.FileType,
parameters filemanager.ParameterTable,
) ([]string, error) {
var dirPath string
fileNames := make([]string, 0)
switch fileType {
case filemanager.AvatarFileType:
userId, ok := parameters[filemanager.UserIdParam]
if !ok {
return nil, filemanager.ErrParameterNotFound
}
dirPath = filepath.Join(s.basePath, userId.(string), "avatars")
case filemanager.CVFileType:
submId, ok := parameters[filemanager.SubmissionIdParam]
if !ok {
return nil, filemanager.ErrParameterNotFound
}
dirPath = filepath.Join(s.basePath, "CVs", submId.(string))
default:
return nil, filemanager.ErrInvalidFileType
}
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
return nil, filemanager.ErrFileNotFound
}
if err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
fileNames = append(fileNames, path)
return nil
}); err != nil {
return nil, err
}
if len(fileNames) == 0 {
return nil, filemanager.ErrFileNotFound
}
return fileNames, nil
}
func (s *Storage) SaveFile(
ctx context.Context,
fileType filemanager.FileType,
file multipart.File,
fileHeader *multipart.FileHeader,
parameters filemanager.ParameterTable,
) error {
var dirPath string
switch fileType {
case filemanager.AvatarFileType:
userId, ok := parameters[filemanager.UserIdParam]
if !ok {
return filemanager.ErrParameterNotFound
}
dirPath = filepath.Join(s.basePath, userId.(string), "avatars")
case filemanager.CVFileType:
submId, ok := parameters[filemanager.SubmissionIdParam]
if !ok {
return filemanager.ErrParameterNotFound
}
dirPath = filepath.Join(s.basePath, "CVs", submId.(string))
default:
return filemanager.ErrInvalidFileType
}
if err := os.MkdirAll(dirPath, 0755); err != nil {
return err
}
filePath := filepath.Join(dirPath, fileHeader.Filename)
dstFile, err := os.Create(filePath)
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, file); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,39 @@
package filemanager
import (
"context"
"mime/multipart"
)
type FileType string
const (
AvatarFileType FileType = "avatar"
CVFileType FileType = "cv"
)
type ParameterType string
const (
UserIdParam ParameterType = "user_id"
VacancyIdParam ParameterType = "vacancy_id"
SubmissionIdParam ParameterType = "submission_id"
)
type ParameterTable map[ParameterType]any
type UserFileManager interface {
GetFilePaths(
ctx context.Context,
fileType FileType,
parameters ParameterTable,
) ([]string, error)
SaveFile(
ctx context.Context,
fileType FileType,
file multipart.File,
fileHeader *multipart.FileHeader,
parameters ParameterTable,
) error
}

View File

@@ -0,0 +1,156 @@
package s3_storage
import (
"bytes"
"context"
"errors"
"fmt"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/cache"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/constants"
filemanager "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/file_manager"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/object_storage"
"github.com/aws/smithy-go/ptr"
"mime"
"mime/multipart"
"path/filepath"
"strings"
"time"
)
type S3Storage struct {
objectStorage object_storage.Client
cacheClient cache.Client
}
func NewS3Storage(objectStorage object_storage.Client, cacheClient cache.Client) *S3Storage {
return &S3Storage{
objectStorage: objectStorage,
cacheClient: cacheClient,
}
}
func (s *S3Storage) GetFilePaths(
ctx context.Context,
fileType filemanager.FileType,
parameters filemanager.ParameterTable,
) ([]string, error) {
objectKey, category, err := s.buildObjectKey(fileType, parameters)
if err != nil {
return nil, err
}
// ะกะฝะฐั‡ะฐะปะฐ ะฟั‹ั‚ะฐะตะผัั ะฟะพะปัƒั‡ะธั‚ัŒ URL ะธะท ะบััˆะฐ
cachedURL, err := s.getCachedURL(ctx, objectKey, category)
if err == nil && cachedURL != "" {
return []string{cachedURL}, nil
}
// ะŸะพะปัƒั‡ะฐะตะผ ะปะธะฝะบ ะธะท s3
url, err := s.objectStorage.GetPresignedLink(ctx, objectKey, category,
object_storage.LinkOptions{
TTL: ptr.Duration(constants.DefaultFileTTL),
})
if err != nil {
if errors.Is(err, object_storage.ErrObjectNotFound) {
return nil, filemanager.ErrFileNotFound
}
return nil, fmt.Errorf("%s: %w", filemanager.ErrMsgGetPresignedURL, err)
}
// ะšััˆะธั€ัƒะตะผ URL
if err := s.cacheURL(ctx, objectKey, category, url); err != nil {
return nil, fmt.Errorf("%s: %w", filemanager.ErrFailedToCacheUrl, err)
}
return []string{url}, nil
}
func (s *S3Storage) SaveFile(
ctx context.Context,
fileType filemanager.FileType,
file multipart.File,
fileHeader *multipart.FileHeader,
parameters filemanager.ParameterTable,
) error {
objectKey, category, err := s.buildObjectKey(fileType, parameters)
if err != nil {
return err
}
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(file); err != nil {
return fmt.Errorf("%s: %w", filemanager.ErrMsgReadFile, err)
}
// ะžะฟั€ะตะดะตะปัะตะผ ั‚ะธะฟ ะบะพะฝั‚ะตะฝั‚ะฐ
contentType := s.getContentType(fileHeader.Filename)
// ะ—ะฐะณั€ัƒะถะฐะตะผ ั„ะฐะนะป ะฒ S3
err = s.objectStorage.PutNewObject(ctx, objectKey, category,
bytes.NewReader(buf.Bytes()),
object_storage.PutOptions{
ContentType: contentType,
})
if err != nil {
// ะžะฑั€ะฐะฑะฐั‚ั‹ะฒะฐะตะผ ัะปัƒั‡ะฐะน ััƒั‰ะตัั‚ะฒัƒัŽั‰ะตะณะพ ั„ะฐะนะปะฐ
if errors.Is(err, object_storage.ErrObjectAlreadyExists) {
return filemanager.ErrFileAlreadyExists
}
return fmt.Errorf("%s: %w", filemanager.ErrFailedUploadFile, err)
}
return nil
}
// ะกะพะทะดะฐะตะผ ะบะปัŽั‡ ะพะฑัŠะตะบั‚ะฐ S3 ะฝะฐ ะพัะฝะพะฒะต ั‚ะธะฟะฐ ั„ะฐะนะปะฐ ะธ ะฟะฐั€ะฐะผะตั‚ั€ะพะฒ
func (s *S3Storage) buildObjectKey(fileType filemanager.FileType, parameters filemanager.ParameterTable) (string, object_storage.Category, error) {
switch fileType {
case filemanager.AvatarFileType:
userID, ok := parameters[filemanager.UserIdParam]
if !ok {
return "", "", filemanager.ErrParameterNotFound
}
return fmt.Sprintf(objectKeyAvatarTemplate, userID), object_storage.LogoCategory, nil
case filemanager.CVFileType:
submissionID, ok := parameters[filemanager.SubmissionIdParam]
if !ok {
return "", "", filemanager.ErrParameterNotFound
}
return fmt.Sprintf(objectKeyCVTemplate, submissionID), object_storage.DocumentCategory, nil
default:
return "", "", filemanager.ErrInvalidFileType
}
}
// getContentType ะพะฟั€ะตะดะตะปัะตั‚ ั‚ะธะฟ ะบะพะฝั‚ะตะฝั‚ะฐ ะฟะพ ั€ะฐััˆะธั€ะตะฝะธัŽ
func (s *S3Storage) getContentType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
if ct, ok := ContentTypes[ext]; ok {
return ct
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return defaultContentType
}
func (s *S3Storage) getCachedURL(ctx context.Context, objectKey string, category object_storage.Category) (string, error) {
cacheKey := fmt.Sprintf(cacheKeyTemplate, category, objectKey)
return s.cacheClient.Get(ctx, cacheKey, cache.DocumentsValueType)
}
func (s *S3Storage) cacheURL(ctx context.Context, objectKey string, category object_storage.Category, url string) error {
cacheKey := fmt.Sprintf(cacheKeyTemplate, category, objectKey)
cacheDuration := constants.DefaultFileTTL - time.Hour
return s.cacheClient.Set(ctx, cacheKey, cache.DocumentsValueType, url, cacheDuration)
}

View File

@@ -0,0 +1,22 @@
package s3_storage
var ContentTypes = map[string]string{
".pdf": "application/pdf",
".txt": "text/plain",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".rtf": "application/rtf",
".odt": "application/vnd.oasis.opendocument.text",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
}
// ะšะพะฝัั‚ะฐะฝั‚ั‹ ะดะปั ะบะปัŽั‡ะตะน
const (
objectKeyAvatarTemplate = "%s_avatar"
objectKeyCVTemplate = "%s_cv"
cacheKeyTemplate = "%s_%s"
defaultContentType = "application/octet-stream"
)

View File

@@ -0,0 +1,65 @@
package formgenerator
import (
"embed"
"errors"
"html/template"
"io/fs"
"strings"
)
//go:embed templates/index.html
var formTemplate string
//go:embed templates
var formFS embed.FS
var (
requiredFields []string = []string{
"VacancyName",
"Address",
"WorkFormat",
"SalaryTop",
"SalaryBottom",
"Requirements",
"Responsibilities",
"Description",
"VacancyId",
"AgentId",
}
)
var (
ErrNotFound = errors.New("One of the required fields is not set")
)
func GetFileSystem() (fs.FS, error) {
formFs, err := fs.Sub(formFS, "templates")
if err != nil {
return nil, err
}
return formFs, nil
}
func GenerateForm(formData map[string]string) (string, error) {
for _, field := range requiredFields {
if _, ok := formData[field]; !ok {
return "", ErrNotFound
}
}
tmpl, err := template.New("index.html").Parse(formTemplate)
if err != nil {
return "", err
}
var buf strings.Builder
if err = tmpl.Execute(&buf, formData); err != nil {
return "", err
}
return buf.String(), nil
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@
"DQUHFmFzc2V0cy9pbWFnZXMvbG9nby5zdmcMAQ0BBwVhc3NldAcWYXNzZXRzL2ltYWdlcy9sb2dvLnN2ZwcdYXNzZXRzL2ltYWdlcy9yZXN1bWUtZmlsZS5zdmcMAQ0BBwVhc3NldAcdYXNzZXRzL2ltYWdlcy9yZXN1bWUtZmlsZS5zdmcHHWFzc2V0cy9pbWFnZXMvcmVzdW1lLWxpbmsuc3ZnDAENAQcFYXNzZXQHHWFzc2V0cy9pbWFnZXMvcmVzdW1lLWxpbmsuc3ZnBxZhc3NldHMvaW1hZ2VzL3NlbmQuc3ZnDAENAQcFYXNzZXQHFmFzc2V0cy9pbWFnZXMvc2VuZC5zdmcHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmDAENAQcFYXNzZXQHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRm"

View File

@@ -0,0 +1 @@
{"assets/images/logo.svg":["assets/images/logo.svg"],"assets/images/resume-file.svg":["assets/images/resume-file.svg"],"assets/images/resume-link.svg":["assets/images/resume-link.svg"],"assets/images/send.svg":["assets/images/send.svg"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]}

View File

@@ -0,0 +1 @@
[{"family":"MaterialIcons","fonts":[{"asset":"fonts/MaterialIcons-Regular.otf"}]},{"family":"packages/cupertino_icons/CupertinoIcons","fonts":[{"asset":"packages/cupertino_icons/assets/CupertinoIcons.ttf"}]}]

View File

@@ -0,0 +1,8 @@
<svg width="138" height="28" viewBox="0 0 138 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.1226 15.512L6.39303 0.519165H0V26.4807H7.23678V16.0636L13.2404 26.4807H16.7776L22.8462 15.7067L22.9111 26.4807H30.1154V0.519165H23.6575L15.1226 15.512Z" fill="#1454B2"/>
<path d="M55.0384 1.7524C52.8641 0.584135 50.3978 0 47.6718 0C44.9459 0 42.4795 0.584135 40.3053 1.7524C38.131 2.92067 36.3786 4.54327 35.1454 6.58774C33.9122 8.63221 33.2632 10.9688 33.2632 13.5325C33.2632 16.0962 33.8798 18.4327 35.1454 20.4772C36.3786 22.5216 38.131 24.1442 40.3053 25.3125C42.4795 26.4808 44.9459 27.0649 47.6718 27.0649C50.3978 27.0649 52.8641 26.4808 55.0384 25.3125C57.2127 24.1442 58.9651 22.5216 60.1983 20.4772C61.4315 18.4327 62.0805 16.0962 62.0805 13.5325C62.0805 10.9688 61.4639 8.63221 60.1983 6.58774C58.9651 4.51082 57.2127 2.88822 55.0384 1.7524ZM50.9819 19.5685C50.0084 20.1526 48.8726 20.4447 47.6718 20.4447C46.4387 20.4447 45.3353 20.1526 44.3293 19.5685C43.3557 18.9844 42.5444 18.1731 41.9927 17.1346C41.4086 16.0962 41.1165 14.863 41.1165 13.5C41.1165 12.137 41.4086 10.9038 41.9927 9.86539C42.5769 8.82692 43.3557 8.01562 44.3293 7.43149C45.3028 6.84736 46.4387 6.55529 47.6718 6.55529C48.905 6.55529 50.0084 6.84736 50.9819 7.43149C51.9555 8.01562 52.7668 8.82692 53.3185 9.86539C53.9026 10.9038 54.1947 12.137 54.1947 13.5C54.1947 14.863 53.9026 16.0962 53.3185 17.1346C52.7668 18.1731 51.9879 19.0168 50.9819 19.5685Z" fill="#1454B2"/>
<path d="M73.0167 0.519165H65.2283V26.4807H85.1213V20.185H73.0167V0.519165Z" fill="#1454B2"/>
<path d="M90.833 0.519165H82.428L93.5265 26.4807H100.99L101.347 25.6694L90.833 0.519165Z" fill="#1454B2"/>
<path d="M104.073 19.2439L112.121 0.519165H104.365L100.276 10.0925L104.073 19.2439Z" fill="#3B9BF2"/>
<path d="M126.465 0.519165H119.423L125.492 15.6418H119.877H112.316L107.546 26.4807H115.529L117.541 21.548H127.861L129.84 26.4807H137.986L126.465 0.519165Z" fill="#1454B2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,192 @@
var CanvasKitInit = (() => {
var _scriptName = import.meta.url;
return (
function(moduleArg = {}) {
var moduleRtn;
var r=moduleArg,aa,ca,da=new Promise((a,b)=>{aa=a;ca=b}),ea="object"==typeof window,ha="function"==typeof importScripts;
(function(a){a.ce=a.ce||[];a.ce.push(function(){a.MakeSWCanvasSurface=function(b){var c=b,e="undefined"!==typeof OffscreenCanvas&&c instanceof OffscreenCanvas;if(!("undefined"!==typeof HTMLCanvasElement&&c instanceof HTMLCanvasElement||e||(c=document.getElementById(b),c)))throw"Canvas with id "+b+" was not found";if(b=a.MakeSurface(c.width,c.height))b.Ae=c;return b};a.MakeCanvasSurface||(a.MakeCanvasSurface=a.MakeSWCanvasSurface);a.MakeSurface=function(b,c){var e={width:b,height:c,colorType:a.ColorType.RGBA_8888,
alphaType:a.AlphaType.Unpremul,colorSpace:a.ColorSpace.SRGB},f=b*c*4,k=a._malloc(f);if(e=a.Surface._makeRasterDirect(e,k,4*b))e.Ae=null,e.$e=b,e.Xe=c,e.Ye=f,e.He=k,e.getCanvas().clear(a.TRANSPARENT);return e};a.MakeRasterDirectSurface=function(b,c,e){return a.Surface._makeRasterDirect(b,c.byteOffset,e)};a.Surface.prototype.flush=function(b){a.$d(this.Zd);this._flush();if(this.Ae){var c=new Uint8ClampedArray(a.HEAPU8.buffer,this.He,this.Ye);c=new ImageData(c,this.$e,this.Xe);b?this.Ae.getContext("2d").putImageData(c,
0,0,b[0],b[1],b[2]-b[0],b[3]-b[1]):this.Ae.getContext("2d").putImageData(c,0,0)}};a.Surface.prototype.dispose=function(){this.He&&a._free(this.He);this.delete()};a.$d=a.$d||function(){};a.Be=a.Be||function(){return null}})})(r);
(function(a){a.ce=a.ce||[];a.ce.push(function(){function b(l,p,v){return l&&l.hasOwnProperty(p)?l[p]:v}function c(l){var p=ja(ka);ka[p]=l;return p}function e(l){return l.naturalHeight||l.videoHeight||l.displayHeight||l.height}function f(l){return l.naturalWidth||l.videoWidth||l.displayWidth||l.width}function k(l,p,v,w){l.bindTexture(l.TEXTURE_2D,p);w||v.alphaType!==a.AlphaType.Premul||l.pixelStorei(l.UNPACK_PREMULTIPLY_ALPHA_WEBGL,!0);return p}function n(l,p,v){v||p.alphaType!==a.AlphaType.Premul||
l.pixelStorei(l.UNPACK_PREMULTIPLY_ALPHA_WEBGL,!1);l.bindTexture(l.TEXTURE_2D,null)}a.GetWebGLContext=function(l,p){if(!l)throw"null canvas passed into makeWebGLContext";var v={alpha:b(p,"alpha",1),depth:b(p,"depth",1),stencil:b(p,"stencil",8),antialias:b(p,"antialias",0),premultipliedAlpha:b(p,"premultipliedAlpha",1),preserveDrawingBuffer:b(p,"preserveDrawingBuffer",0),preferLowPowerToHighPerformance:b(p,"preferLowPowerToHighPerformance",0),failIfMajorPerformanceCaveat:b(p,"failIfMajorPerformanceCaveat",
0),enableExtensionsByDefault:b(p,"enableExtensionsByDefault",1),explicitSwapControl:b(p,"explicitSwapControl",0),renderViaOffscreenBackBuffer:b(p,"renderViaOffscreenBackBuffer",0)};v.majorVersion=p&&p.majorVersion?p.majorVersion:"undefined"!==typeof WebGL2RenderingContext?2:1;if(v.explicitSwapControl)throw"explicitSwapControl is not supported";l=la(l,v);if(!l)return 0;oa(l);z.le.getExtension("WEBGL_debug_renderer_info");return l};a.deleteContext=function(l){z===pa[l]&&(z=null);"object"==typeof JSEvents&&
JSEvents.Af(pa[l].le.canvas);pa[l]&&pa[l].le.canvas&&(pa[l].le.canvas.Ve=void 0);pa[l]=null};a._setTextureCleanup({deleteTexture:function(l,p){var v=ka[p];v&&pa[l].le.deleteTexture(v);ka[p]=null}});a.MakeWebGLContext=function(l){if(!this.$d(l))return null;var p=this._MakeGrContext();if(!p)return null;p.Zd=l;var v=p.delete.bind(p);p["delete"]=function(){a.$d(this.Zd);v()}.bind(p);return z.Je=p};a.MakeGrContext=a.MakeWebGLContext;a.GrDirectContext.prototype.getResourceCacheLimitBytes=function(){a.$d(this.Zd);
this._getResourceCacheLimitBytes()};a.GrDirectContext.prototype.getResourceCacheUsageBytes=function(){a.$d(this.Zd);this._getResourceCacheUsageBytes()};a.GrDirectContext.prototype.releaseResourcesAndAbandonContext=function(){a.$d(this.Zd);this._releaseResourcesAndAbandonContext()};a.GrDirectContext.prototype.setResourceCacheLimitBytes=function(l){a.$d(this.Zd);this._setResourceCacheLimitBytes(l)};a.MakeOnScreenGLSurface=function(l,p,v,w,A,D){if(!this.$d(l.Zd))return null;p=void 0===A||void 0===D?
this._MakeOnScreenGLSurface(l,p,v,w):this._MakeOnScreenGLSurface(l,p,v,w,A,D);if(!p)return null;p.Zd=l.Zd;return p};a.MakeRenderTarget=function(){var l=arguments[0];if(!this.$d(l.Zd))return null;if(3===arguments.length){var p=this._MakeRenderTargetWH(l,arguments[1],arguments[2]);if(!p)return null}else if(2===arguments.length){if(p=this._MakeRenderTargetII(l,arguments[1]),!p)return null}else return null;p.Zd=l.Zd;return p};a.MakeWebGLCanvasSurface=function(l,p,v){p=p||null;var w=l,A="undefined"!==
typeof OffscreenCanvas&&w instanceof OffscreenCanvas;if(!("undefined"!==typeof HTMLCanvasElement&&w instanceof HTMLCanvasElement||A||(w=document.getElementById(l),w)))throw"Canvas with id "+l+" was not found";l=this.GetWebGLContext(w,v);if(!l||0>l)throw"failed to create webgl context: err "+l;l=this.MakeWebGLContext(l);p=this.MakeOnScreenGLSurface(l,w.width,w.height,p);return p?p:(p=w.cloneNode(!0),w.parentNode.replaceChild(p,w),p.classList.add("ck-replaced"),a.MakeSWCanvasSurface(p))};a.MakeCanvasSurface=
a.MakeWebGLCanvasSurface;a.Surface.prototype.makeImageFromTexture=function(l,p){a.$d(this.Zd);l=c(l);if(p=this._makeImageFromTexture(this.Zd,l,p))p.ue=l;return p};a.Surface.prototype.makeImageFromTextureSource=function(l,p,v){p||={height:e(l),width:f(l),colorType:a.ColorType.RGBA_8888,alphaType:v?a.AlphaType.Premul:a.AlphaType.Unpremul};p.colorSpace||(p.colorSpace=a.ColorSpace.SRGB);a.$d(this.Zd);var w=z.le;v=k(w,w.createTexture(),p,v);2===z.version?w.texImage2D(w.TEXTURE_2D,0,w.RGBA,p.width,p.height,
0,w.RGBA,w.UNSIGNED_BYTE,l):w.texImage2D(w.TEXTURE_2D,0,w.RGBA,w.RGBA,w.UNSIGNED_BYTE,l);n(w,p);this._resetContext();return this.makeImageFromTexture(v,p)};a.Surface.prototype.updateTextureFromSource=function(l,p,v){if(l.ue){a.$d(this.Zd);var w=l.getImageInfo(),A=z.le,D=k(A,ka[l.ue],w,v);2===z.version?A.texImage2D(A.TEXTURE_2D,0,A.RGBA,f(p),e(p),0,A.RGBA,A.UNSIGNED_BYTE,p):A.texImage2D(A.TEXTURE_2D,0,A.RGBA,A.RGBA,A.UNSIGNED_BYTE,p);n(A,w,v);this._resetContext();ka[l.ue]=null;l.ue=c(D);w.colorSpace=
l.getColorSpace();p=this._makeImageFromTexture(this.Zd,l.ue,w);v=l.Yd.ae;A=l.Yd.ee;l.Yd.ae=p.Yd.ae;l.Yd.ee=p.Yd.ee;p.Yd.ae=v;p.Yd.ee=A;p.delete();w.colorSpace.delete()}};a.MakeLazyImageFromTextureSource=function(l,p,v){p||={height:e(l),width:f(l),colorType:a.ColorType.RGBA_8888,alphaType:v?a.AlphaType.Premul:a.AlphaType.Unpremul};p.colorSpace||(p.colorSpace=a.ColorSpace.SRGB);var w={makeTexture:function(){var A=z,D=A.le,I=k(D,D.createTexture(),p,v);2===A.version?D.texImage2D(D.TEXTURE_2D,0,D.RGBA,
p.width,p.height,0,D.RGBA,D.UNSIGNED_BYTE,l):D.texImage2D(D.TEXTURE_2D,0,D.RGBA,D.RGBA,D.UNSIGNED_BYTE,l);n(D,p,v);return c(I)},freeSrc:function(){}};"VideoFrame"===l.constructor.name&&(w.freeSrc=function(){l.close()});return a.Image._makeFromGenerator(p,w)};a.$d=function(l){return l?oa(l):!1};a.Be=function(){return z&&z.Je&&!z.Je.isDeleted()?z.Je:null}})})(r);
(function(a){function b(g){return(f(255*g[3])<<24|f(255*g[0])<<16|f(255*g[1])<<8|f(255*g[2])<<0)>>>0}function c(g){if(g&&g._ck)return g;if(g instanceof Float32Array){for(var d=Math.floor(g.length/4),h=new Uint32Array(d),m=0;m<d;m++)h[m]=b(g.slice(4*m,4*(m+1)));return h}if(g instanceof Uint32Array)return g;if(g instanceof Array&&g[0]instanceof Float32Array)return g.map(b)}function e(g){if(void 0===g)return 1;var d=parseFloat(g);return g&&-1!==g.indexOf("%")?d/100:d}function f(g){return Math.round(Math.max(0,
Math.min(g||0,255)))}function k(g,d){d&&d._ck||a._free(g)}function n(g,d,h){if(!g||!g.length)return K;if(g&&g._ck)return g.byteOffset;var m=a[d].BYTES_PER_ELEMENT;h||=a._malloc(g.length*m);a[d].set(g,h/m);return h}function l(g){var d={he:K,count:g.length,colorType:a.ColorType.RGBA_F32};if(g instanceof Float32Array)d.he=n(g,"HEAPF32"),d.count=g.length/4;else if(g instanceof Uint32Array)d.he=n(g,"HEAPU32"),d.colorType=a.ColorType.RGBA_8888;else if(g instanceof Array){if(g&&g.length){for(var h=a._malloc(16*
g.length),m=0,t=h/4,u=0;u<g.length;u++)for(var x=0;4>x;x++)a.HEAPF32[t+m]=g[u][x],m++;g=h}else g=K;d.he=g}else throw"Invalid argument to copyFlexibleColorArray, Not a color array "+typeof g;return d}function p(g){if(!g)return K;var d=ba.toTypedArray();if(g.length){if(6===g.length||9===g.length)return n(g,"HEAPF32",P),6===g.length&&a.HEAPF32.set(Wc,6+P/4),P;if(16===g.length)return d[0]=g[0],d[1]=g[1],d[2]=g[3],d[3]=g[4],d[4]=g[5],d[5]=g[7],d[6]=g[12],d[7]=g[13],d[8]=g[15],P;throw"invalid matrix size";
}if(void 0===g.m11)throw"invalid matrix argument";d[0]=g.m11;d[1]=g.m21;d[2]=g.m41;d[3]=g.m12;d[4]=g.m22;d[5]=g.m42;d[6]=g.m14;d[7]=g.m24;d[8]=g.m44;return P}function v(g){if(!g)return K;var d=Y.toTypedArray();if(g.length){if(16!==g.length&&6!==g.length&&9!==g.length)throw"invalid matrix size";if(16===g.length)return n(g,"HEAPF32",ma);d.fill(0);d[0]=g[0];d[1]=g[1];d[3]=g[2];d[4]=g[3];d[5]=g[4];d[7]=g[5];d[10]=1;d[12]=g[6];d[13]=g[7];d[15]=g[8];6===g.length&&(d[12]=0,d[13]=0,d[15]=1);return ma}if(void 0===
g.m11)throw"invalid matrix argument";d[0]=g.m11;d[1]=g.m21;d[2]=g.m31;d[3]=g.m41;d[4]=g.m12;d[5]=g.m22;d[6]=g.m32;d[7]=g.m42;d[8]=g.m13;d[9]=g.m23;d[10]=g.m33;d[11]=g.m43;d[12]=g.m14;d[13]=g.m24;d[14]=g.m34;d[15]=g.m44;return ma}function w(g,d){return n(g,"HEAPF32",d||ia)}function A(g,d,h,m){var t=Ea.toTypedArray();t[0]=g;t[1]=d;t[2]=h;t[3]=m;return ia}function D(g){for(var d=new Float32Array(4),h=0;4>h;h++)d[h]=a.HEAPF32[g/4+h];return d}function I(g,d){return n(g,"HEAPF32",d||W)}function R(g,d){return n(g,
"HEAPF32",d||ub)}a.Color=function(g,d,h,m){void 0===m&&(m=1);return a.Color4f(f(g)/255,f(d)/255,f(h)/255,m)};a.ColorAsInt=function(g,d,h,m){void 0===m&&(m=255);return(f(m)<<24|f(g)<<16|f(d)<<8|f(h)<<0&268435455)>>>0};a.Color4f=function(g,d,h,m){void 0===m&&(m=1);return Float32Array.of(g,d,h,m)};Object.defineProperty(a,"TRANSPARENT",{get:function(){return a.Color4f(0,0,0,0)}});Object.defineProperty(a,"BLACK",{get:function(){return a.Color4f(0,0,0,1)}});Object.defineProperty(a,"WHITE",{get:function(){return a.Color4f(1,
1,1,1)}});Object.defineProperty(a,"RED",{get:function(){return a.Color4f(1,0,0,1)}});Object.defineProperty(a,"GREEN",{get:function(){return a.Color4f(0,1,0,1)}});Object.defineProperty(a,"BLUE",{get:function(){return a.Color4f(0,0,1,1)}});Object.defineProperty(a,"YELLOW",{get:function(){return a.Color4f(1,1,0,1)}});Object.defineProperty(a,"CYAN",{get:function(){return a.Color4f(0,1,1,1)}});Object.defineProperty(a,"MAGENTA",{get:function(){return a.Color4f(1,0,1,1)}});a.getColorComponents=function(g){return[Math.floor(255*
g[0]),Math.floor(255*g[1]),Math.floor(255*g[2]),g[3]]};a.parseColorString=function(g,d){g=g.toLowerCase();if(g.startsWith("#")){d=255;switch(g.length){case 9:d=parseInt(g.slice(7,9),16);case 7:var h=parseInt(g.slice(1,3),16);var m=parseInt(g.slice(3,5),16);var t=parseInt(g.slice(5,7),16);break;case 5:d=17*parseInt(g.slice(4,5),16);case 4:h=17*parseInt(g.slice(1,2),16),m=17*parseInt(g.slice(2,3),16),t=17*parseInt(g.slice(3,4),16)}return a.Color(h,m,t,d/255)}return g.startsWith("rgba")?(g=g.slice(5,
-1),g=g.split(","),a.Color(+g[0],+g[1],+g[2],e(g[3]))):g.startsWith("rgb")?(g=g.slice(4,-1),g=g.split(","),a.Color(+g[0],+g[1],+g[2],e(g[3]))):g.startsWith("gray(")||g.startsWith("hsl")||!d||(g=d[g],void 0===g)?a.BLACK:g};a.multiplyByAlpha=function(g,d){g=g.slice();g[3]=Math.max(0,Math.min(g[3]*d,1));return g};a.Malloc=function(g,d){var h=a._malloc(d*g.BYTES_PER_ELEMENT);return{_ck:!0,length:d,byteOffset:h,qe:null,subarray:function(m,t){m=this.toTypedArray().subarray(m,t);m._ck=!0;return m},toTypedArray:function(){if(this.qe&&
this.qe.length)return this.qe;this.qe=new g(a.HEAPU8.buffer,h,d);this.qe._ck=!0;return this.qe}}};a.Free=function(g){a._free(g.byteOffset);g.byteOffset=K;g.toTypedArray=null;g.qe=null};var P=K,ba,ma=K,Y,ia=K,Ea,fa,W=K,Ub,Ba=K,Vb,vb=K,Wb,wb=