1
This commit is contained in:
313
internal/integration/client.go
Normal file
313
internal/integration/client.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/cache"
|
||||
cache2 "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/cache"
|
||||
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database"
|
||||
dberrors "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/errors"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
HandleVacancyResponse(request HandleVacancy) error
|
||||
}
|
||||
|
||||
type (
|
||||
CompanySecrets any
|
||||
|
||||
client struct {
|
||||
cache cache2.Client
|
||||
db database.Client
|
||||
logger *slog.Logger
|
||||
secrets map[string]CompanySecrets
|
||||
}
|
||||
|
||||
Config struct {
|
||||
Cache cache2.Client
|
||||
Db database.Client
|
||||
Logger *slog.Logger
|
||||
Secrets map[string]CompanySecrets
|
||||
}
|
||||
)
|
||||
|
||||
func New(c Config) (Client, error) {
|
||||
return &client{
|
||||
cache: c.Cache,
|
||||
db: c.Db,
|
||||
logger: c.Logger,
|
||||
secrets: c.Secrets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TODO: need to think about possible errors
|
||||
// e.g. cache is down, connection lost etc.
|
||||
|
||||
func (c *client) HandleVacancyResponse(request HandleVacancy) error {
|
||||
const handlerName = "integration.HandleVacancyResponse"
|
||||
|
||||
companyId, vacancyExtraFields, err := c.getVacancyIntegrationInfo(context.Background(), request.VacancyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if vacancyExtraFields == nil {
|
||||
c.logger.Debug("vacancy extra fields are empty",
|
||||
slog.String("vacancy_id", request.VacancyId),
|
||||
slog.String("handler", handlerName),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
companyMetadata, companyExtraFieldsTemplate, err := c.getCompanyIntegrationInfo(context.Background(), companyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if companyMetadata == nil {
|
||||
c.logger.Debug("company metadata is empty",
|
||||
slog.String("company_id", companyId),
|
||||
slog.String("handler", handlerName),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if companyExtraFieldsTemplate == nil {
|
||||
c.logger.Debug("company extra fields template are empty",
|
||||
slog.String("company_id", companyId),
|
||||
slog.String("handler", handlerName),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
companyName, ok := (*companyMetadata)[companyNameMetadataKey]
|
||||
if !ok {
|
||||
return fmt.Errorf("company name not found in metadata")
|
||||
}
|
||||
|
||||
switch companyName {
|
||||
case VkusvillCompanyName:
|
||||
return c.handleVkusvillIntegration(
|
||||
context.Background(),
|
||||
request,
|
||||
vacancyExtraFields,
|
||||
companyMetadata,
|
||||
companyExtraFieldsTemplate,
|
||||
)
|
||||
|
||||
default:
|
||||
return ErrUnknownIntegration
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocognit // not that hard
|
||||
func (c *client) getVacancyIntegrationInfo(
|
||||
ctx context.Context,
|
||||
vacancyId string,
|
||||
) (string, *VacancyExtraFields, error) {
|
||||
const handlerName = "integration.getVacancyIntegrationInfo"
|
||||
|
||||
var (
|
||||
companyId string
|
||||
vacancyExtraFields VacancyExtraFields
|
||||
gotCached = true
|
||||
)
|
||||
|
||||
// get company id and vacancy filled template
|
||||
cachedIntegrationInfo, err := c.cache.Get(context.Background(), vacancyId, cache.IntegrationVacancyValueType)
|
||||
if err != nil && !errors.Is(err, cache.ErrKeyNotFound) {
|
||||
if err := tryRepeat(context.Background(), func() error {
|
||||
cachedIntegrationInfo, err = c.cache.Get(context.Background(), vacancyId, cache.IntegrationCompanyValueType)
|
||||
return err
|
||||
}, defaultRetryAmount); err != nil {
|
||||
// TODO: maybe we should process further and try DB instead
|
||||
// Need to handle the error deeper
|
||||
// e.g. if cache is down we can try to get info from DB
|
||||
// on the other hand, if there is any other fatal error, we should return it
|
||||
return "", nil, fmt.Errorf("error getting vacancy integration info from cache: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(err, cache.ErrKeyNotFound) {
|
||||
// try get data from DB
|
||||
dbCompanyId, dbFields, err := c.db.GetVacancyIntegrationInfoById(context.Background(), vacancyId)
|
||||
if err != nil {
|
||||
if errors.Is(err, dberrors.ErrNotFound) {
|
||||
return "", nil, ErrVacancyNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultRetryTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := tryRepeat(ctx, func() error {
|
||||
dbCompanyId, dbFields, err = c.db.GetVacancyIntegrationInfoById(context.Background(), vacancyId)
|
||||
return err
|
||||
}, 0); err != nil {
|
||||
return "", nil, fmt.Errorf("error getting vacancy integration info from DB: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
companyId = dbCompanyId
|
||||
vacancyExtraFields = *new(VacancyExtraFields).From(dbFields)
|
||||
gotCached = false
|
||||
|
||||
// store obtained info in cache
|
||||
fullMetadata := VacancyIntegrationInfo{
|
||||
CompanyId: companyId,
|
||||
ExtraFields: vacancyExtraFields,
|
||||
}
|
||||
|
||||
jsonMetadata, err := json.Marshal(fullMetadata)
|
||||
if err != nil {
|
||||
c.logger.Error("error marshalling vacancy integration info",
|
||||
slog.String("error", err.Error()),
|
||||
slog.String("vacancy_id", vacancyId),
|
||||
slog.String("handler", handlerName))
|
||||
} else {
|
||||
if err := tryRepeat(context.Background(), func() error {
|
||||
return c.cache.Set(context.Background(), vacancyId, cache.IntegrationVacancyValueType, string(jsonMetadata), 0)
|
||||
}, defaultRetryAmount); err != nil {
|
||||
c.logger.Error("error saving vacancy integration info to cache",
|
||||
slog.String("error", err.Error()),
|
||||
slog.String("vacancy_id", vacancyId),
|
||||
slog.String("handler", handlerName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gotCached {
|
||||
var fullMetadata VacancyIntegrationInfo
|
||||
if err := json.Unmarshal([]byte(cachedIntegrationInfo), &fullMetadata); err != nil {
|
||||
return "", nil, fmt.Errorf("error unmarshalling cached metadata: %w", err)
|
||||
}
|
||||
|
||||
companyId = fullMetadata.CompanyId
|
||||
vacancyExtraFields = fullMetadata.ExtraFields
|
||||
}
|
||||
|
||||
return companyId, &vacancyExtraFields, nil
|
||||
}
|
||||
|
||||
//nolint:gocognit // not that hard
|
||||
func (c *client) getCompanyIntegrationInfo(
|
||||
ctx context.Context,
|
||||
companyId string,
|
||||
) (*CompanyMetadata, *CompanyExtraFieldsTemplate, error) {
|
||||
const handlerName = "integration.getCompanyIntegrationInfo"
|
||||
|
||||
var (
|
||||
companyMetadata CompanyMetadata
|
||||
companyFieldsTemplate CompanyExtraFieldsTemplate
|
||||
gotCached = true
|
||||
)
|
||||
|
||||
// get company metadata and template
|
||||
cachedIntegrationInfo, err := c.cache.Get(context.Background(), companyId, cache.IntegrationCompanyValueType)
|
||||
if err != nil && !errors.Is(err, cache.ErrKeyNotFound) {
|
||||
if err := tryRepeat(context.Background(), func() error {
|
||||
cachedIntegrationInfo, err = c.cache.Get(context.Background(), companyId, cache.IntegrationCompanyValueType)
|
||||
return err
|
||||
}, defaultRetryAmount); err != nil {
|
||||
// TODO: maybe we should process further and try DB instead
|
||||
// Need to handle the error deeper
|
||||
// e.g. if cache is down we can try to get info from DB
|
||||
// on the other hand, if there is any other fatal error, we should return it
|
||||
return nil, nil, fmt.Errorf("error getting company metadata from cache: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(err, cache.ErrKeyNotFound) {
|
||||
// try get data from DB
|
||||
dbMetadata, dbFieldsTemplate, err := c.db.GetCompanyMetadataById(context.Background(), companyId)
|
||||
if err != nil {
|
||||
if errors.Is(err, dberrors.ErrNotFound) {
|
||||
return nil, nil, ErrCompanyNotFound
|
||||
}
|
||||
|
||||
// TODO: think about other retry policies
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultRetryTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := tryRepeat(ctx, func() error {
|
||||
dbMetadata, dbFieldsTemplate, err = c.db.GetCompanyMetadataById(context.Background(), companyId)
|
||||
return err
|
||||
}, 0); err != nil {
|
||||
return nil, nil, fmt.Errorf("error getting company integration info from DB: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
companyMetadata = *new(CompanyMetadata).From(dbMetadata)
|
||||
companyFieldsTemplate = *new(CompanyExtraFieldsTemplate).From(dbFieldsTemplate)
|
||||
gotCached = false
|
||||
|
||||
// store obtained info in cache
|
||||
fullMetadata := CompanyIntegrationInfo{
|
||||
Metadata: companyMetadata,
|
||||
ExtraFieldsTemplate: companyFieldsTemplate,
|
||||
}
|
||||
|
||||
jsonMetadata, err := json.Marshal(fullMetadata)
|
||||
if err != nil {
|
||||
c.logger.Error("error marshalling company integration info",
|
||||
slog.String("error", err.Error()),
|
||||
slog.String("company_id", companyId),
|
||||
slog.String("handler", handlerName))
|
||||
} else {
|
||||
if err := tryRepeat(context.Background(), func() error {
|
||||
return c.cache.Set(context.Background(), companyId, cache.IntegrationCompanyValueType, string(jsonMetadata), 0)
|
||||
}, defaultRetryAmount); err != nil {
|
||||
c.logger.Error("error saving company integration info to cache",
|
||||
slog.String("error", err.Error()),
|
||||
slog.String("company_id", companyId),
|
||||
slog.String("handler", handlerName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gotCached {
|
||||
var fullMetadata CompanyIntegrationInfo
|
||||
if err := json.Unmarshal([]byte(cachedIntegrationInfo), &fullMetadata); err != nil {
|
||||
return nil, nil, fmt.Errorf("error unmarshalling cached metadata: %w", err)
|
||||
}
|
||||
|
||||
companyMetadata = fullMetadata.Metadata
|
||||
companyFieldsTemplate = fullMetadata.ExtraFieldsTemplate
|
||||
}
|
||||
|
||||
return &companyMetadata, &companyFieldsTemplate, nil
|
||||
}
|
||||
|
||||
// tryRepeat tries to execute function N times.
|
||||
// If number of attempts is 0, the function will be executed until context is canceled
|
||||
func tryRepeat(ctx context.Context, fn func() error, attempts uint) error {
|
||||
ch := make(chan error, 1)
|
||||
|
||||
for iter := uint(0); iter < attempts || attempts == 0; iter++ {
|
||||
go func() {
|
||||
ch <- fn()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-ch:
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if attempts != 0 && iter == attempts-1 {
|
||||
return err
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
8
internal/integration/constants.go
Normal file
8
internal/integration/constants.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package integration
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
defaultRetryAmount = 5
|
||||
defaultRetryTimeout = 20 * time.Minute
|
||||
)
|
201
internal/integration/domain.go
Normal file
201
internal/integration/domain.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
|
||||
)
|
||||
|
||||
type (
|
||||
Candidate struct {
|
||||
Id string
|
||||
Name string
|
||||
Email string
|
||||
Phone string
|
||||
Birthday string
|
||||
Info string
|
||||
CvLink string
|
||||
}
|
||||
|
||||
HandleVacancy struct {
|
||||
AgentId string
|
||||
VacancyId string
|
||||
SourceLid string
|
||||
Candidate Candidate
|
||||
}
|
||||
)
|
||||
|
||||
type FieldType string
|
||||
|
||||
const (
|
||||
BooleanFieldType FieldType = "bool"
|
||||
IntegerFieldType FieldType = "int"
|
||||
FloatFieldType FieldType = "float"
|
||||
StringFieldType FieldType = "string"
|
||||
StructFieldType FieldType = "struct"
|
||||
)
|
||||
|
||||
func convertFieldtype(typ FieldType, value string) (any, error) {
|
||||
switch typ {
|
||||
case BooleanFieldType:
|
||||
return strconv.ParseBool(value)
|
||||
case IntegerFieldType:
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
case FloatFieldType:
|
||||
return strconv.ParseFloat(value, 64)
|
||||
case StringFieldType:
|
||||
return value, nil
|
||||
case StructFieldType:
|
||||
// TODO: maybe convert it to map[string]any?
|
||||
return value, nil
|
||||
default:
|
||||
return nil, ErrUnknownFieldType
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
/*
|
||||
CompanyIntegrationInfo describes full metadata stored for company
|
||||
*/
|
||||
CompanyIntegrationInfo struct {
|
||||
Metadata CompanyMetadata `json:"metadata"`
|
||||
ExtraFieldsTemplate CompanyExtraFieldsTemplate `json:"template"`
|
||||
}
|
||||
)
|
||||
|
||||
func (m *CompanyMetadata) From(msg *dbtypes.CompanyMetadata) *CompanyMetadata {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for k, v := range *msg {
|
||||
(*m)[k] = v
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (t *CompanyExtraFieldsTemplate) From(msg *dbtypes.CompanyExtraFieldsTemplate) *CompanyExtraFieldsTemplate {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var res CompanyExtraFieldsTemplate
|
||||
|
||||
res.Fields = make([]CompanyExtraFieldTemplate, len(msg.Fields))
|
||||
|
||||
for i, v := range msg.Fields {
|
||||
res.Fields[i] = CompanyExtraFieldTemplate{
|
||||
SystemName: v.SystemName,
|
||||
DisplayName: v.DisplayName,
|
||||
Type: FieldType(v.Type),
|
||||
Required: v.Required,
|
||||
MultipleChoice: v.MultipleChoice,
|
||||
ValidValues: make([]ValidValue, len(v.ValidValues)),
|
||||
}
|
||||
|
||||
for j, vv := range v.ValidValues {
|
||||
res.Fields[i].ValidValues[j] = ValidValue{
|
||||
DisplayName: vv.DisplayName,
|
||||
Value: vv.Value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &res
|
||||
}
|
||||
|
||||
type (
|
||||
/*
|
||||
VacancyExtraField describes single filled
|
||||
additional field of vacancy additional fields
|
||||
*/
|
||||
VacancyExtraField struct {
|
||||
SystemName string `json:"system_name"`
|
||||
Type FieldType `json:"type"`
|
||||
MultipleChoice bool `json:"multiple_choice"`
|
||||
IsValidValue bool `json:"is_valid_value"`
|
||||
Value []string `json:"value"`
|
||||
}
|
||||
|
||||
/*
|
||||
VacancyExtraFields describes additional fields
|
||||
filled during vacancy creation
|
||||
*/
|
||||
VacancyExtraFields struct {
|
||||
Fields []VacancyExtraField `json:"fields"`
|
||||
}
|
||||
|
||||
/*
|
||||
VacancyIntegrationInfo describes full metadata stored for vacancy
|
||||
*/
|
||||
VacancyIntegrationInfo struct {
|
||||
CompanyId string `json:"company_id"`
|
||||
ExtraFields VacancyExtraFields `json:"extra_fields"`
|
||||
}
|
||||
)
|
||||
|
||||
func (f *VacancyExtraField) From(msg *dbtypes.VacancyExtraFieldTemplate) *VacancyExtraField {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := VacancyExtraField{
|
||||
SystemName: msg.SystemName,
|
||||
Type: FieldType(msg.Type),
|
||||
IsValidValue: msg.IsValidValue,
|
||||
Value: msg.Value,
|
||||
}
|
||||
|
||||
return &res
|
||||
}
|
||||
|
||||
func (f *VacancyExtraFields) From(msg *dbtypes.VacancyExtraFieldsTemplate) *VacancyExtraFields {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var res VacancyExtraFields
|
||||
|
||||
res.Fields = make([]VacancyExtraField, len(msg.Fields))
|
||||
|
||||
for i, v := range msg.Fields {
|
||||
res.Fields[i] = *new(VacancyExtraField).From(&v)
|
||||
}
|
||||
|
||||
return &res
|
||||
}
|
||||
|
||||
const (
|
||||
companyNameMetadataKey = "company_name"
|
||||
|
||||
VkusvillCompanyName = "vkusvill"
|
||||
)
|
12
internal/integration/errors.go
Normal file
12
internal/integration/errors.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package integration
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidConfig = errors.New("invalid config")
|
||||
ErrUnknownIntegration = errors.New("unknown integration company")
|
||||
ErrUnknownFieldType = errors.New("unknown field type")
|
||||
ErrUnknownCandidateStatus = errors.New("unknown candidate status")
|
||||
ErrVacancyNotFound = errors.New("vacancy not found")
|
||||
ErrCompanyNotFound = errors.New("company not found")
|
||||
)
|
344
internal/integration/vkusvill.go
Normal file
344
internal/integration/vkusvill.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
rmodel "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/request_model"
|
||||
)
|
||||
|
||||
const (
|
||||
vkusvillMolvaUtmSource = "molva"
|
||||
|
||||
dmYFormat = "02.01.2006" // datetime format stored in DB
|
||||
YmdFormat = "2006-01-02" // datetime format used in vkusvill
|
||||
)
|
||||
|
||||
var (
|
||||
// TODO: use reflect to set json tags
|
||||
vkusvillVacancyIdFieldKey = "vacancy_id"
|
||||
|
||||
vkusvillNeededFields = map[string]struct{}{
|
||||
vkusvillVacancyIdFieldKey: {},
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
VkusvillSecretsConfig struct {
|
||||
ApiToken string
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
vkusvillSourceLid struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
vkusvillSaveCandidateRequest struct {
|
||||
Status int `json:"status"` // see status mapping
|
||||
LastName string `json:"last_name"` // required
|
||||
Name string `json:"name"` // required
|
||||
SecondName string `json:"second_name"` // required
|
||||
Birthday string `json:"birthday"` // YYYY-mm-dd
|
||||
Phone []string `json:"phone"`
|
||||
VacancyId int `json:"vacancy_id"` // see vacancy to id mapping
|
||||
CityJob string `json:"city_job"` // unsupported now
|
||||
MetroArea string `json:"metro_area"` // unsupported now
|
||||
UtmSource string `json:"utm_source"`
|
||||
SourceLid vkusvillSourceLid `json:"source_lid"`
|
||||
}
|
||||
)
|
||||
|
||||
func (c *client) handleVkusvillIntegration(
|
||||
ctx context.Context,
|
||||
request HandleVacancy,
|
||||
vacancyExtraFields *VacancyExtraFields,
|
||||
companyMetadata *CompanyMetadata,
|
||||
companyExtraFieldsTemplate *CompanyExtraFieldsTemplate,
|
||||
) error {
|
||||
host, ok := (*companyMetadata)["host"]
|
||||
if !ok {
|
||||
return fmt.Errorf("host not found in metadata")
|
||||
}
|
||||
|
||||
method, ok := (*companyMetadata)["method"]
|
||||
if !ok {
|
||||
return fmt.Errorf("request method not found in metadata")
|
||||
}
|
||||
|
||||
lastName, name, secondName := splitName(request.Candidate.Name)
|
||||
|
||||
extraFields, err := parseVacancyExtraFields(vacancyExtraFields, companyExtraFieldsTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error extracting vacancy id: %w", err)
|
||||
}
|
||||
|
||||
vacancyId, ok := extraFields[vkusvillVacancyIdFieldKey].(int)
|
||||
if !ok {
|
||||
return fmt.Errorf("vacancy id field is not of expected type (int)")
|
||||
}
|
||||
|
||||
sourceUrl, err := url.Parse(request.SourceLid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source lid: %w", err)
|
||||
}
|
||||
|
||||
query := sourceUrl.Query()
|
||||
// TODO: think what we potentially want (maybe request id for tracing etc.)
|
||||
query.Set("candidate_id", request.Candidate.Id)
|
||||
|
||||
sourceUrl.RawQuery = query.Encode()
|
||||
|
||||
saveCandidateReq := vkusvillSaveCandidateRequest{
|
||||
Status: 0,
|
||||
LastName: lastName,
|
||||
Name: name,
|
||||
SecondName: secondName,
|
||||
Birthday: formatDate(request.Candidate.Birthday, dmYFormat, YmdFormat),
|
||||
Phone: []string{request.Candidate.Phone},
|
||||
VacancyId: vacancyId,
|
||||
CityJob: "",
|
||||
MetroArea: "",
|
||||
UtmSource: vkusvillMolvaUtmSource,
|
||||
SourceLid: vkusvillSourceLid{
|
||||
Url: sourceUrl.String(),
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := c.vkusvillSaveCandidate(method, host, &saveCandidateReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making request: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
// TODO: think about following:
|
||||
// у Вкусвилла ошибки возвращаются в теле ответа. Соответственно, нужно
|
||||
// а) проверить наличие ошибки
|
||||
// б) если ошибка есть, создать алерт об этом (например, в админку). Вообще надо подумать над этим сценарием
|
||||
|
||||
return c.vkusvillHandleResponse(resp)
|
||||
}
|
||||
|
||||
type (
|
||||
VkusvillSaveCandidateResponse struct {
|
||||
//nolint:tagliatelle // used in vkusvill
|
||||
Error bool `json:"ERR"`
|
||||
//nolint:tagliatelle // used in vkusvill
|
||||
Data json.RawMessage `json:"DATA"`
|
||||
}
|
||||
|
||||
VkusvillErrorMessage struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
VkusvillErrorDetails struct {
|
||||
Data []VkusvillErrorMessage
|
||||
}
|
||||
|
||||
VkusvillSuccessDetails struct {
|
||||
// TODO: maybe add more fields
|
||||
}
|
||||
)
|
||||
|
||||
func (r *VkusvillSaveCandidateResponse) GetErrorDetails() (*VkusvillErrorDetails, error) {
|
||||
var errDetails VkusvillErrorDetails
|
||||
|
||||
if err := json.Unmarshal(r.Data, &errDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &errDetails, nil
|
||||
}
|
||||
|
||||
func (r *VkusvillSaveCandidateResponse) GetSuccessDetails() (*VkusvillSuccessDetails, error) {
|
||||
var successDetails VkusvillSuccessDetails
|
||||
|
||||
if err := json.Unmarshal(r.Data, &successDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &successDetails, nil
|
||||
}
|
||||
|
||||
func (c *client) vkusvillHandleResponse(resp *http.Response) error {
|
||||
const handlerName = "integration.vkusvillHandleResponse"
|
||||
|
||||
var vkusvillResp VkusvillSaveCandidateResponse
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&vkusvillResp); err != nil {
|
||||
return fmt.Errorf("error decoding vkusvill response: %w", err)
|
||||
}
|
||||
|
||||
if vkusvillResp.Error {
|
||||
errDetails, err := vkusvillResp.GetErrorDetails()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting vkusvill error details: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("error saving candidate in vkusvill HRM: %v", errDetails)
|
||||
}
|
||||
|
||||
successDetails, err := vkusvillResp.GetSuccessDetails()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting vkusvill success details: %w", err)
|
||||
}
|
||||
|
||||
// TODO: remove after testing
|
||||
c.logger.Debug("vkusvill response body",
|
||||
slog.String("body", string(vkusvillResp.Data)),
|
||||
slog.String("handler", handlerName))
|
||||
|
||||
c.logger.Debug("vkusvill success details",
|
||||
slog.Any("success_details", successDetails),
|
||||
slog.String("handler", handlerName))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitName(name string) (string, string, string) {
|
||||
splittedName := strings.SplitN(name, " ", 3)
|
||||
|
||||
var (
|
||||
lastName, firstName, secondName string
|
||||
)
|
||||
|
||||
if len(splittedName) > 1 {
|
||||
lastName = splittedName[0]
|
||||
}
|
||||
|
||||
if len(splittedName) > 2 {
|
||||
firstName = splittedName[1]
|
||||
}
|
||||
|
||||
if len(splittedName) > 3 {
|
||||
secondName = splittedName[2]
|
||||
}
|
||||
|
||||
return lastName, firstName, secondName
|
||||
}
|
||||
|
||||
func formatDate(date, fromFmt, toFmt string) string {
|
||||
parsed, err := time.Parse(fromFmt, date)
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
|
||||
return parsed.Format(toFmt)
|
||||
}
|
||||
|
||||
//nolint:gocognit // TODO: refactor
|
||||
func parseVacancyExtraFields(
|
||||
vacancyExtraFields *VacancyExtraFields,
|
||||
companyExtraFieldsTemplate *CompanyExtraFieldsTemplate,
|
||||
) (map[string]any, error) {
|
||||
resMap := make(map[string]any)
|
||||
|
||||
for _, vacXField := range vacancyExtraFields.Fields {
|
||||
if _, ok := vkusvillNeededFields[vacXField.SystemName]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(vacXField.Value) == 0 {
|
||||
// TODO: Maybe here we need to check whether corresponding field template
|
||||
// has required flag set to true
|
||||
continue
|
||||
}
|
||||
|
||||
if vacXField.IsValidValue {
|
||||
// get corresponding value from company template
|
||||
for _, comFieldTemplate := range companyExtraFieldsTemplate.Fields {
|
||||
if comFieldTemplate.SystemName != vacXField.SystemName {
|
||||
continue
|
||||
}
|
||||
|
||||
if vacXField.MultipleChoice {
|
||||
var (
|
||||
nameValMap = make(map[string]string, len(comFieldTemplate.ValidValues))
|
||||
res = make([]any, len(vacXField.Value))
|
||||
err error
|
||||
)
|
||||
|
||||
for _, validVal := range comFieldTemplate.ValidValues {
|
||||
nameValMap[validVal.DisplayName] = validVal.Value
|
||||
}
|
||||
|
||||
for i, val := range vacXField.Value {
|
||||
res[i], err = convertFieldtype(vacXField.Type, nameValMap[val])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting field value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
resMap[vacXField.SystemName] = res
|
||||
} else {
|
||||
for _, validVal := range comFieldTemplate.ValidValues {
|
||||
if validVal.DisplayName != vacXField.Value[0] {
|
||||
continue
|
||||
}
|
||||
|
||||
res, err := convertFieldtype(vacXField.Type, validVal.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting field value: %w", err)
|
||||
}
|
||||
|
||||
resMap[vacXField.SystemName] = res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if vacXField.MultipleChoice {
|
||||
var (
|
||||
res = make([]any, len(vacXField.Value))
|
||||
err error
|
||||
)
|
||||
|
||||
for i, v := range vacXField.Value {
|
||||
res[i], err = convertFieldtype(vacXField.Type, v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting field value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
resMap[vacXField.SystemName] = res
|
||||
} else {
|
||||
res, err := convertFieldtype(vacXField.Type, vacXField.Value[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting field value: %w", err)
|
||||
}
|
||||
|
||||
resMap[vacXField.SystemName] = res
|
||||
}
|
||||
}
|
||||
|
||||
return resMap, nil
|
||||
}
|
||||
|
||||
func VkusvillGetCandidateStatus(status int) (rmodel.SubmissionStatus, error) {
|
||||
switch status {
|
||||
case 0: // Статус не установлен
|
||||
return rmodel.SubStatusUnspecified, nil
|
||||
case 666: // Архивный
|
||||
return rmodel.SubStatusCancelled, nil
|
||||
case 49: // Без вакансии
|
||||
return rmodel.SubStatusCancelled, nil
|
||||
case 50: // На рассмотрении
|
||||
return rmodel.SubStatusOnInterview, nil
|
||||
case 51: // Отклоненный
|
||||
return rmodel.SubStatusRejected, nil
|
||||
case 115: // Зарезервированный
|
||||
return rmodel.SubStatusApproved, nil
|
||||
default:
|
||||
return rmodel.SubStatusUnspecified, ErrUnknownCandidateStatus
|
||||
}
|
||||
}
|
36
internal/integration/vkusvill_request.go
Normal file
36
internal/integration/vkusvill_request.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (c *client) vkusvillSaveCandidate(method, url string, request *vkusvillSaveCandidateRequest) (*http.Response, error) {
|
||||
secrets, ok := c.secrets[VkusvillCompanyName].(VkusvillSecretsConfig)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("vkusvill secrets are not of expected type")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshaling request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Molva/1.0")
|
||||
req.Header.Set("Authorization", secrets.ApiToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error sending request: %w", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
Reference in New Issue
Block a user