760 lines
16 KiB
Go
760 lines
16 KiB
Go
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
|
|
}
|
|
|
|
func (c *Client) GetJWTByTelegramUser(
|
|
ctx context.Context,
|
|
telegramID int64,
|
|
username *string,
|
|
firstName *string,
|
|
lastName *string,
|
|
) (string, error) {
|
|
q := url.Values{}
|
|
q.Set("telegram_id", fmt.Sprint(telegramID))
|
|
if username != nil && *username != "" {
|
|
q.Set("username", *username)
|
|
}
|
|
if firstName != nil && *firstName != "" {
|
|
q.Set("first_name", *firstName)
|
|
}
|
|
if lastName != nil && *lastName != "" {
|
|
q.Set("last_name", *lastName)
|
|
}
|
|
|
|
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) CreateWorkspace(
|
|
ctx context.Context,
|
|
jwt string,
|
|
name string,
|
|
) (*Workspace, error) {
|
|
req := struct {
|
|
Name string `json:"name"`
|
|
}{
|
|
Name: name,
|
|
}
|
|
|
|
var workspace Workspace
|
|
err := c.do(
|
|
ctx,
|
|
http.MethodPost,
|
|
"api/v1/workspaces",
|
|
req,
|
|
&workspace,
|
|
withBearer(jwt),
|
|
)
|
|
|
|
return &workspace, 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"`
|
|
MediaType string `json:"media_type"`
|
|
MediaFileID string `json:"media_file_id"`
|
|
Buttons []CreativeButton `json:"buttons"`
|
|
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 CreativeButton struct {
|
|
Text string `json:"text"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
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"`
|
|
MediaType string `json:"media_type,omitempty"`
|
|
MediaFileID string `json:"media_file_id,omitempty"`
|
|
MediaData []byte `json:"media_data,omitempty"`
|
|
Buttons []CreativeButton `json:"buttons,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
MediaType *string `json:"media_type,omitempty"`
|
|
MediaFileID *string `json:"media_file_id,omitempty"`
|
|
MediaData *[]byte `json:"media_data,omitempty"`
|
|
Buttons *[]CreativeButton `json:"buttons,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
|
|
}
|