package backend import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" ) var ErrNotFound = errors.New("not found") type Config struct { BaseURL string `envconfig:"BACKEND__BASE_URL" default:"http_v1://localhost:8000"` LoginURL string `envconfig:"LOGIN_URL" required:"true"` } type Client struct { http *http.Client baseURL string loginURL string } func New(cfg Config) *Client { return &Client{ http: &http.Client{ Timeout: 5 * time.Second, }, baseURL: cfg.BaseURL, loginURL: cfg.LoginURL, } } func withBearer(token string) func(*http.Request) { return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) } } func (c *Client) do(ctx context.Context, method string, path string, in any, out any, opts ...func(*http.Request)) error { var body io.Reader if in != nil { b, err := json.Marshal(in) if err != nil { return fmt.Errorf("json.Marshal: %w", err) } body = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+"/"+path, body) if err != nil { return fmt.Errorf("http_v1.NewRequest: %w", err) } if in != nil { req.Header.Set("Content-Type", "application/json") } for _, opt := range opts { opt(req) } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("client.Do: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return ErrNotFound } if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) return fmt.Errorf("request failed: %s: %s", resp.Status, b) } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("json.Decode: %w", err) } } return nil } func (c *Client) GetLoginURL(token string) string { return c.loginURL + token } func (c *Client) CreateLoginToken( ctx context.Context, telegramID int64, username *string, ) (string, error) { req := struct { TelegramID int64 `json:"telegram_id"` Username *string `json:"username"` }{ TelegramID: telegramID, Username: username, } var token string err := c.do( ctx, http.MethodPost, "api/v1/internal/auth/login-token", req, &token, ) return token, err } func (c *Client) GetJWTByTelegramID( ctx context.Context, telegramID int64, ) (string, error) { q := url.Values{} q.Set("telegram_id", fmt.Sprint(telegramID)) path := "api/v1/internal/auth/jwt?" + q.Encode() var resp struct { AccessToken string `json:"access_token"` } err := c.do(ctx, http.MethodGet, path, nil, &resp) return resp.AccessToken, err } type Workspace struct { ID string `json:"id"` Name string `json:"name"` } type Project struct { ID string `json:"id"` TelegramID int64 `json:"telegram_id"` Title string `json:"title"` Username *string `json:"username"` Status string `json:"status"` PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"` } type Page struct { Items []Project `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` Pages int `json:"pages"` } func (c *Client) GetWorkspaces( ctx context.Context, jwt string, ) ([]Workspace, error) { var resp struct { Items []Workspace `json:"items"` } err := c.do( ctx, http.MethodGet, "api/v1/workspaces", nil, &resp, withBearer(jwt), ) return resp.Items, err } func (c *Client) GetProjects( ctx context.Context, jwt string, workspaceID string, page, size int, ) (*Page, error) { q := url.Values{} q.Set("page", fmt.Sprint(page)) q.Set("size", fmt.Sprint(size)) path := fmt.Sprintf("api/v1/workspaces/%s/projects?%s", workspaceID, q.Encode()) var resp Page err := c.do( ctx, http.MethodGet, path, nil, &resp, withBearer(jwt), ) return &resp, err } func (c *Client) GetProject( ctx context.Context, workspaceID string, projectID string, ) (*Project, error) { path := fmt.Sprintf("api/v1/internal/projects/%s/%s", workspaceID, projectID) var project Project err := c.do( ctx, http.MethodGet, path, nil, &project, ) return &project, err } func (c *Client) UpdateProjectInviteLinkType( ctx context.Context, jwt string, workspaceID string, projectID string, inviteLinkType string, // "public" или "approval" ) (*Project, error) { path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID) payload := map[string]string{ "purchase_invite_type_default": inviteLinkType, } var project Project err := c.do( ctx, http.MethodPatch, path, payload, &project, withBearer(jwt), ) return &project, err } func (c *Client) ForwardTelegramUpdate( ctx context.Context, update any, ) error { return c.do( ctx, http.MethodPost, "api/v1/internal/telegram/updates", update, nil, ) } func (c *Client) AttachChannelToWorkspace( ctx context.Context, channelID string, workspaceID string, userTelegramID int64, ) (*Project, error) { input := map[string]any{ "channel_id": channelID, "workspace_id": workspaceID, "user_telegram_id": userTelegramID, } var project Project err := c.do( ctx, http.MethodPost, "api/v1/internal/projects", input, &project, ) return &project, err } func (c *Client) AcceptWorkspaceInvite( ctx context.Context, jwt string, inviteID string, ) error { path := fmt.Sprintf("api/v1/invites/%s/accept", inviteID) return c.do( ctx, http.MethodPost, path, nil, nil, withBearer(jwt), ) } // ============================================================================ // Creatives // ============================================================================ type Creative struct { ID string `json:"id"` Name string `json:"name"` Text string `json:"text"` ProjectID string `json:"project_id"` ProjectChannelTitle string `json:"project_channel_title"` CreatedAt string `json:"created_at"` Status string `json:"status"` PlacementsCount int `json:"placements_count"` } type CreativesPage struct { Items []Creative `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` Pages int `json:"pages"` } func (c *Client) GetCreatives( ctx context.Context, jwt string, workspaceID string, projectID *string, includeArchived bool, page, size int, ) (*CreativesPage, error) { q := url.Values{} if projectID != nil { q.Set("project_id", *projectID) } q.Set("include_archived", fmt.Sprint(includeArchived)) q.Set("page", fmt.Sprint(page)) q.Set("size", fmt.Sprint(size)) path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode()) var resp CreativesPage err := c.do( ctx, http.MethodGet, path, nil, &resp, withBearer(jwt), ) return &resp, err } func (c *Client) GetCreative( ctx context.Context, jwt string, workspaceID string, creativeID string, ) (*Creative, error) { path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) var creative Creative err := c.do( ctx, http.MethodGet, path, nil, &creative, withBearer(jwt), ) return &creative, err } type CreateCreativeInput struct { Name string `json:"name"` Text string `json:"text"` } func (c *Client) CreateCreative( ctx context.Context, jwt string, workspaceID string, projectID string, input CreateCreativeInput, ) (*Creative, error) { q := url.Values{} q.Set("project_id", projectID) path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode()) var creative Creative err := c.do( ctx, http.MethodPost, path, input, &creative, withBearer(jwt), ) return &creative, err } type UpdateCreativeInput struct { Name *string `json:"name,omitempty"` Text *string `json:"text,omitempty"` Status *string `json:"status,omitempty"` } func (c *Client) UpdateCreative( ctx context.Context, jwt string, workspaceID string, creativeID string, input UpdateCreativeInput, ) (*Creative, error) { path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) var creative Creative err := c.do( ctx, http.MethodPatch, path, input, &creative, withBearer(jwt), ) return &creative, err } func (c *Client) DeleteCreative( ctx context.Context, jwt string, workspaceID string, creativeID string, ) error { path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) return c.do( ctx, http.MethodDelete, path, nil, nil, withBearer(jwt), ) } // ============================================================================ // Placements (Размещения/Закупы) // ============================================================================ type Placement struct { ID string `json:"id"` ProjectID string `json:"project_id"` ProjectChannelTitle *string `json:"project_channel_title"` PlacementChannelID string `json:"placement_channel_id"` PlacementChannelTitle *string `json:"placement_channel_title"` CreativeID string `json:"creative_id"` CreativeName string `json:"creative_name"` PlacementDate string `json:"placement_date"` Cost *float64 `json:"cost"` Comment *string `json:"comment"` AdPostURL *string `json:"ad_post_url"` InviteLinkType string `json:"invite_link_type"` InviteLink string `json:"invite_link"` Status string `json:"status"` SubscriptionsCount int `json:"subscriptions_count"` PurchaseID *string `json:"purchase_id"` PurchaseChannelID *string `json:"purchase_channel_id"` CreatedAt string `json:"created_at"` } type PlacementsPage struct { Items []Placement `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` Pages int `json:"pages"` } func (c *Client) GetPlacements( ctx context.Context, jwt string, workspaceID string, projectID *string, placementChannelID *string, creativeID *string, includeArchived bool, page, size int, ) (*PlacementsPage, error) { q := url.Values{} if projectID != nil { q.Set("project_id", *projectID) } if placementChannelID != nil { q.Set("placement_channel_id", *placementChannelID) } if creativeID != nil { q.Set("creative_id", *creativeID) } q.Set("include_archived", fmt.Sprint(includeArchived)) q.Set("page", fmt.Sprint(page)) q.Set("size", fmt.Sprint(size)) path := fmt.Sprintf("api/v1/workspaces/%s/placements?%s", workspaceID, q.Encode()) var resp PlacementsPage err := c.do( ctx, http.MethodGet, path, nil, &resp, withBearer(jwt), ) return &resp, err } func (c *Client) GetPlacement( ctx context.Context, jwt string, workspaceID string, placementID string, ) (*Placement, error) { path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID) var placement Placement err := c.do( ctx, http.MethodGet, path, nil, &placement, withBearer(jwt), ) return &placement, err } // Note: Placement creation/updates are handled by worker; API exposes only read endpoints. // ============================================================================ // Purchases (Закупы) // ============================================================================ type Channel struct { ID string `json:"id"` TelegramID *int64 `json:"telegram_id"` Title *string `json:"title"` Username *string `json:"username"` } type Purchase struct { ID string `json:"id"` Status string `json:"status"` CreativeID string `json:"creative_id"` Channels []PurchaseChannelOut `json:"channels"` } type PurchaseChannelOut struct { ID string `json:"id"` Status string `json:"status"` PlannedCost *float64 `json:"planned_cost"` Comment *string `json:"comment"` InviteLink string `json:"invite_link"` InviteLinkType string `json:"invite_link_type"` Channel Channel `json:"channel"` } type CreatePurchaseChannelInput struct { Username string `json:"username"` Status *string `json:"status,omitempty"` PlannedCost *float64 `json:"planned_cost,omitempty"` Comment *string `json:"comment,omitempty"` } type CreatePurchaseInput struct { CreativeID string `json:"creative_id"` Channels []CreatePurchaseChannelInput `json:"channels"` } func (c *Client) CreatePurchase( ctx context.Context, jwt string, workspaceID string, projectID string, input CreatePurchaseInput, ) (*Purchase, error) { path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID) var purchase Purchase err := c.do( ctx, http.MethodPost, path, input, &purchase, withBearer(jwt), ) return &purchase, err } func (c *Client) GetPurchases( ctx context.Context, jwt string, workspaceID string, projectID string, ) ([]Purchase, error) { path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID) var resp struct { Items []Purchase `json:"items"` } err := c.do( ctx, http.MethodGet, path, nil, &resp, withBearer(jwt), ) return resp.Items, err } func (c *Client) GetPurchase( ctx context.Context, jwt string, workspaceID string, projectID string, purchaseID string, ) (*Purchase, error) { path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase/%s", workspaceID, projectID, purchaseID) var purchase Purchase err := c.do( ctx, http.MethodGet, path, nil, &purchase, withBearer(jwt), ) return &purchase, err } // ============================================================================ // Workspace Members // ============================================================================ type WorkspaceMember struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` UserID string `json:"user_id"` Username string `json:"username"` } func (c *Client) GetWorkspaceMembers( ctx context.Context, jwt string, workspaceID string, ) ([]WorkspaceMember, error) { path := fmt.Sprintf("api/v1/workspaces/%s/members", workspaceID) var resp struct { Items []WorkspaceMember `json:"items"` } err := c.do( ctx, http.MethodGet, path, nil, &resp, withBearer(jwt), ) return resp.Items, err }