Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 50 additions & 9 deletions internal/api/content_changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ import "errors"

// ContentChange represents a draft change set
type ContentChange struct {
SCID string `json:"sc_id"`
SFID string `json:"sfid,omitempty"`
Status string `json:"status"`
CustomData map[string]interface{} `json:"custom_data,omitempty"`
SCID string `json:"sc_id"`
SFID string `json:"sfid,omitempty"`
Status string `json:"status"`
Summary string `json:"summary,omitempty"`
RecordsCount int `json:"records_count,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
CustomData map[string]interface{} `json:"custom_data,omitempty"`
}

// ContentChangeRequest represents a request to create/update content changes
type ContentChangeRequest struct {
ThemeID string `json:"theme_id,omitempty"`
Templates []ContentChangeTemplate `json:"templates,omitempty"`
Assets []ContentChangeAsset `json:"assets,omitempty"`
}

// ContentChangeTemplate represents a template change
Expand All @@ -23,6 +27,17 @@ type ContentChangeTemplate struct {
Action string `json:"action"` // "create", "update", or "delete"
}

// ContentChangeAsset represents an uploaded or changed binary asset that
// should be registered on the draft. URL is the hosted location returned by
// the media upload flow; ContentHash lets the server skip re-processing
// unchanged binaries.
type ContentChangeAsset struct {
Key string `json:"key"`
URL string `json:"url"`
ContentType string `json:"content_type,omitempty"`
ContentHash string `json:"content_hash,omitempty"`
}

// PreviewURLResponse represents the preview URL response
type PreviewURLResponse struct {
PreviewURL string `json:"preview_url"`
Expand Down Expand Up @@ -62,17 +77,43 @@ func (cc *ContentChanges) Create(themeID string) (*ContentChange, error) {
return &result, nil
}

// Update adds template changes to an existing content change
func (cc *ContentChanges) Update(id string, themeID string, templates []ContentChangeTemplate) error {
body := map[string]interface{}{
"theme_id": themeID,
"templates": templates,
// Update adds template and asset changes to an existing content change.
//
// Assets are the binaries already uploaded through the media flow; passing an
// empty slice simply omits them from the request body.
func (cc *ContentChanges) Update(id string, themeID string, templates []ContentChangeTemplate, assets []ContentChangeAsset) error {
body := ContentChangeRequest{
ThemeID: themeID,
Templates: templates,
Assets: assets,
}

var result ContentChange
return cc.client.Patch("/api/v1/content_changes/"+id, body, &result)
}

// List returns the store's content changes, optionally filtered by status.
//
// Drafts created from one machine can be discovered and resumed from another
// by listing them here.
func (cc *ContentChanges) List(status string) ([]ContentChange, error) {
var result struct {
Data []ContentChange `json:"data"`
}

var params map[string]string
if status != "" {
params = map[string]string{"status": status}
}

err := cc.client.Get("/api/v1/content_changes", &result, params)
if err != nil {
return nil, err
}

return result.Data, nil
}

// GetPreviewURL gets the preview URL for a content change
func (cc *ContentChanges) GetPreviewURL(id string) (string, error) {
var result PreviewURLResponse
Expand Down
84 changes: 83 additions & 1 deletion internal/api/content_changes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,92 @@ func TestContentChangesUpdate(t *testing.T) {
client := NewClient(server.URL, "test-store", "test-key")
ccService := NewContentChanges(client)

err := ccService.Update("cc-123", "theme-123", templates)
err := ccService.Update("cc-123", "theme-123", templates, nil)
require.NoError(t, err)
}

func TestContentChangesUpdateIncludesAssets(t *testing.T) {
templates := []ContentChangeTemplate{
{Key: "pages/home", Content: "<h1>Home</h1>", Action: "update"},
}
assets := []ContentChangeAsset{
{Key: "images/logo.png", URL: "https://cdn.example.com/logo.png", ContentType: "image/png", ContentHash: "hash1"},
}

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/content_changes/cc-123", r.URL.Path)
assert.Equal(t, http.MethodPatch, r.Method)

var body map[string]interface{}
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))

require.Contains(t, body, "assets")
rawAssets, ok := body["assets"].([]interface{})
require.True(t, ok)
require.Len(t, rawAssets, 1)

asset, ok := rawAssets[0].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "images/logo.png", asset["key"])
assert.Equal(t, "https://cdn.example.com/logo.png", asset["url"])
assert.Equal(t, "image/png", asset["content_type"])
assert.Equal(t, "hash1", asset["content_hash"])

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(ContentChange{SCID: "cc-123", Status: "draft"})
}))
defer server.Close()

client := NewClient(server.URL, "test-store", "test-key")
ccService := NewContentChanges(client)

err := ccService.Update("cc-123", "theme-123", templates, assets)
require.NoError(t, err)
}

func TestContentChangesList(t *testing.T) {
tests := []struct {
name string
status string
wantStatus string
wantLen int
}{
{name: "no status filter", status: "", wantStatus: "", wantLen: 2},
{name: "filtered by status", status: "draft", wantStatus: "draft", wantLen: 2},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/content_changes", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, tt.wantStatus, r.URL.Query().Get("status"))

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"data": []ContentChange{
{SCID: "cc-1", Status: "draft", Summary: "First", RecordsCount: 3, CreatedAt: "2026-06-12T00:00:00Z"},
{SCID: "cc-2", Status: "draft", Summary: "Second", RecordsCount: 1},
},
})
}))
defer server.Close()

client := NewClient(server.URL, "test-store", "test-key")
ccService := NewContentChanges(client)

changes, err := ccService.List(tt.status)
require.NoError(t, err)
require.Len(t, changes, tt.wantLen)
assert.Equal(t, "cc-1", changes[0].SCID)
assert.Equal(t, "First", changes[0].Summary)
assert.Equal(t, 3, changes[0].RecordsCount)
})
}
}

func TestContentChangesGetPreviewURL(t *testing.T) {
tests := []struct {
name string
Expand Down
112 changes: 98 additions & 14 deletions internal/api/media.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
package api

import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"time"
)

// MediaAsset represents a media asset
type MediaAsset struct {
SCID string `json:"sc_id"`
Expand All @@ -10,20 +20,46 @@ type MediaAsset struct {
Size int64 `json:"size,omitempty"`
}

// UploadURLResponse represents the upload URL response
// UploadURLResponse represents the response from POST /api/v1/media/upload_url.
//
// The server hands back a target URL plus a set of form parameters that must
// accompany the binary in the follow-up multipart POST.
type UploadURLResponse struct {
UploadURL string `json:"upload_url"`
AssetID string `json:"asset_id"`
UploadURL string `json:"upload_url"`
UploadParams map[string]string `json:"upload_params"`
AssetID string `json:"asset_id,omitempty"`
}

// UploadResult is the parsed response from the upload host. The hosted URL is
// "secure_url" when present, falling back to "url".
type UploadResult struct {
SecureURL string `json:"secure_url"`
URL string `json:"url"`
}

// HostedURL returns the canonical hosted location, preferring the secure URL.
func (r UploadResult) HostedURL() string {
if r.SecureURL != "" {
return r.SecureURL
}
return r.URL
}

// Media handles media-related endpoints
type Media struct {
client *Client

// uploader performs the multipart POST to the (external) upload host.
// It is overridable in tests; production uses the default HTTP client.
uploader *http.Client
}

// NewMedia creates a new Media service
func NewMedia(client *Client) *Media {
return &Media{client: client}
return &Media{
client: client,
uploader: &http.Client{Timeout: 60 * time.Second},
}
}

// List returns all media assets
Expand All @@ -40,9 +76,13 @@ func (m *Media) List() ([]MediaAsset, error) {
return result.Media, nil
}

// GetUploadURL gets a presigned upload URL
func (m *Media) GetUploadURL(filename, contentType string) (*UploadURLResponse, error) {
// UploadURL requests a signed upload target for a single file.
//
// fileType is the StoreConnect media kind ("image" or "document"), filename is
// the asset key/name, and contentType is the MIME type inferred from the file.
func (m *Media) UploadURL(fileType, filename, contentType string) (*UploadURLResponse, error) {
body := map[string]interface{}{
"file_type": fileType,
"filename": filename,
"content_type": contentType,
}
Expand All @@ -56,17 +96,61 @@ func (m *Media) GetUploadURL(filename, contentType string) (*UploadURLResponse,
return &result, nil
}

// ConfirmUpload confirms a completed upload
func (m *Media) ConfirmUpload(assetID string) (*MediaAsset, error) {
body := map[string]interface{}{
"asset_id": assetID,
// UploadFile streams the binary to the upload host as a multipart POST,
// sending every upload param as a form field plus the bytes as the "file"
// field. It returns the hosted URL the server assigned to the upload.
func (m *Media) UploadFile(upload *UploadURLResponse, filename string, content []byte) (string, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)

for key, value := range upload.UploadParams {
if err := writer.WriteField(key, value); err != nil {
return "", fmt.Errorf("failed to write upload param %q: %w", key, err)
}
}

var result MediaAsset
err := m.client.Post("/api/v1/media/confirm_upload", body, &result)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return nil, err
return "", fmt.Errorf("failed to create file field: %w", err)
}
if _, err := part.Write(content); err != nil {
return "", fmt.Errorf("failed to write file contents: %w", err)
}

return &result, nil
if err := writer.Close(); err != nil {
return "", fmt.Errorf("failed to finalize upload body: %w", err)
}

req, err := http.NewRequest(http.MethodPost, upload.UploadURL, &buf)
if err != nil {
return "", fmt.Errorf("failed to build upload request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())

resp, err := m.uploader.Do(req)
if err != nil {
return "", fmt.Errorf("failed to upload file: %w", err)
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read upload response: %w", err)
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
}

var result UploadResult
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("failed to parse upload response: %w", err)
}

hosted := result.HostedURL()
if hosted == "" {
return "", fmt.Errorf("upload response missing hosted URL")
}

return hosted, nil
}
Loading