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

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
}