fix publish record deletion
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Frontend CI / Frontend (push) Successful in 2m47s
Backend CI / Backend (push) Failing after 10m1s
Desktop Client Build / Build Desktop Client (push) Successful in 23m3s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 29s
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Frontend CI / Frontend (push) Successful in 2m47s
Backend CI / Backend (push) Failing after 10m1s
Desktop Client Build / Build Desktop Client (push) Successful in 23m3s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 29s
This commit is contained in:
@@ -759,6 +759,20 @@ func (s *DesktopTaskService) leaseNextQueuedPublishTask(
|
||||
AND dt.kind = 'publish'
|
||||
AND dt.status = 'queued'
|
||||
AND dt.attempts < $4
|
||||
AND (
|
||||
NOT (dt.payload ? 'publish_record_id')
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM publish_records pr
|
||||
WHERE pr.tenant_id = dt.tenant_id
|
||||
AND pr.id = CASE
|
||||
WHEN (dt.payload->>'publish_record_id') ~ '^[0-9]+$'
|
||||
THEN (dt.payload->>'publish_record_id')::bigint
|
||||
ELSE NULL
|
||||
END
|
||||
AND pr.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM platform_accounts AS pa
|
||||
@@ -866,6 +880,21 @@ func leaseQueuedDesktopTaskByIDSQL() string {
|
||||
AND dt.workspace_id = $2
|
||||
AND dt.status = 'queued'
|
||||
AND (dt.kind <> 'publish' OR dt.attempts < $7)
|
||||
AND (
|
||||
dt.kind <> 'publish'
|
||||
OR NOT (dt.payload ? 'publish_record_id')
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM publish_records pr
|
||||
WHERE pr.tenant_id = dt.tenant_id
|
||||
AND pr.id = CASE
|
||||
WHEN (dt.payload->>'publish_record_id') ~ '^[0-9]+$'
|
||||
THEN (dt.payload->>'publish_record_id')::bigint
|
||||
ELSE NULL
|
||||
END
|
||||
AND pr.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
AND (
|
||||
dt.kind <> 'publish'
|
||||
OR EXISTS (
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type MediaService struct {
|
||||
@@ -82,11 +83,15 @@ type DeletePublishRecordResponse struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type publishRecordDeleteOwner struct {
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
UserID int64
|
||||
Source string
|
||||
}
|
||||
|
||||
const visiblePublishRecordWhereSQL = `
|
||||
AND (
|
||||
pr.deleted_at IS NULL
|
||||
OR pr.status IN ('success', 'published', 'publish_success', 'failed', 'publish_failed', 'cancelled', 'canceled')
|
||||
)
|
||||
AND pr.deleted_at IS NULL
|
||||
`
|
||||
|
||||
func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformResponse, error) {
|
||||
@@ -327,10 +332,7 @@ func (s *MediaService) countPublishRecordsByStatuses(
|
||||
AND pb.tenant_id = pr.tenant_id
|
||||
AND pb.initiator_user_id = $2
|
||||
)
|
||||
AND (
|
||||
pr.deleted_at IS NULL
|
||||
OR pr.status IN ('success', 'published', 'publish_success', 'failed', 'publish_failed', 'cancelled', 'canceled')
|
||||
)
|
||||
`+visiblePublishRecordWhereSQL+`
|
||||
AND pr.status = ANY($3)
|
||||
AND (
|
||||
$4 = ''
|
||||
@@ -378,10 +380,7 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
|
||||
AND pr.article_id = $2
|
||||
AND a.brand_id = $3
|
||||
AND a.deleted_at IS NULL
|
||||
AND (
|
||||
pr.deleted_at IS NULL
|
||||
OR pr.status IN ('success', 'published', 'publish_success', 'failed', 'publish_failed', 'cancelled', 'canceled')
|
||||
)
|
||||
`+visiblePublishRecordWhereSQL+`
|
||||
ORDER BY pr.created_at DESC, pr.id DESC
|
||||
`, tenantID, articleID, brandID)
|
||||
if err != nil {
|
||||
@@ -433,7 +432,35 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
||||
return nil, response.ErrBadRequest(40001, "invalid_publish_record_id", "publish record id must be a positive number")
|
||||
}
|
||||
actor := auth.MustActor(ctx)
|
||||
if actor.TenantID == 0 || actor.UserID == 0 {
|
||||
workspaceID := auth.CurrentWorkspaceID(ctx)
|
||||
if actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 0 {
|
||||
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
||||
}
|
||||
return s.deletePublishRecordForOwner(ctx, recordID, publishRecordDeleteOwner{
|
||||
TenantID: actor.TenantID,
|
||||
WorkspaceID: workspaceID,
|
||||
UserID: actor.UserID,
|
||||
Source: "tenant_web",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MediaService) DeleteDesktopPublishRecord(ctx context.Context, client *repository.DesktopClient, recordID int64) (*DeletePublishRecordResponse, error) {
|
||||
if recordID <= 0 {
|
||||
return nil, response.ErrBadRequest(40001, "invalid_publish_record_id", "publish record id must be a positive number")
|
||||
}
|
||||
if client == nil || client.TenantID == 0 || client.WorkspaceID == 0 || client.UserID == 0 {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
return s.deletePublishRecordForOwner(ctx, recordID, publishRecordDeleteOwner{
|
||||
TenantID: client.TenantID,
|
||||
WorkspaceID: client.WorkspaceID,
|
||||
UserID: client.UserID,
|
||||
Source: "desktop_client",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MediaService) deletePublishRecordForOwner(ctx context.Context, recordID int64, owner publishRecordDeleteOwner) (*DeletePublishRecordResponse, error) {
|
||||
if owner.TenantID == 0 || owner.WorkspaceID == 0 || owner.UserID == 0 {
|
||||
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
||||
}
|
||||
|
||||
@@ -445,17 +472,16 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
||||
|
||||
var publishBatchID int64
|
||||
var articleID int64
|
||||
var publishStatus string
|
||||
cancelErrorJSONBytes, err := marshalOptionalJSON(map[string]any{
|
||||
"reason": "publish_record_deleted",
|
||||
"source": "tenant_web",
|
||||
"source": owner.Source,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50044, "publish_record_delete_payload_failed", "failed to delete publish record")
|
||||
}
|
||||
cancelErrorJSON := string(cancelErrorJSONBytes)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT pr.publish_batch_id, pr.article_id, pr.status
|
||||
SELECT pr.publish_batch_id, pr.article_id
|
||||
FROM publish_records pr
|
||||
JOIN articles a
|
||||
ON a.id = pr.article_id
|
||||
@@ -466,9 +492,44 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
||||
WHERE pr.id = $1
|
||||
AND pr.tenant_id = $2
|
||||
AND pb.initiator_user_id = $3
|
||||
AND (
|
||||
(
|
||||
COALESCE(pr.target_type, 'platform_account') = 'platform_account'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM platform_accounts pa
|
||||
WHERE pa.id = pr.platform_account_id
|
||||
AND pa.tenant_id = pr.tenant_id
|
||||
AND pa.workspace_id = $4
|
||||
)
|
||||
)
|
||||
OR (
|
||||
COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM enterprise_site_connections esc
|
||||
WHERE esc.id = pr.target_connection_id
|
||||
AND esc.tenant_id = pr.tenant_id
|
||||
AND esc.workspace_id = $4
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM desktop_tasks dt
|
||||
JOIN desktop_publish_jobs j
|
||||
ON j.desktop_id = dt.job_id
|
||||
AND j.tenant_id = dt.tenant_id
|
||||
AND j.workspace_id = dt.workspace_id
|
||||
WHERE dt.tenant_id = pr.tenant_id
|
||||
AND dt.workspace_id = $4
|
||||
AND dt.kind = 'publish'
|
||||
AND dt.payload->>'publish_record_id' = pr.id::text
|
||||
AND j.created_by_user_id = $3
|
||||
)
|
||||
)
|
||||
AND pr.deleted_at IS NULL
|
||||
FOR UPDATE OF pr
|
||||
`, recordID, actor.TenantID, actor.UserID).Scan(&publishBatchID, &articleID, &publishStatus)
|
||||
`, recordID, owner.TenantID, owner.UserID, owner.WorkspaceID).Scan(&publishBatchID, &articleID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrNotFound(40435, "publish_record_not_found", "publish record not found")
|
||||
@@ -476,10 +537,6 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
||||
return nil, response.ErrInternal(50044, "publish_record_delete_lookup_failed", "failed to delete publish record")
|
||||
}
|
||||
|
||||
if !publishRecordMayBeDiscarded(publishStatus) {
|
||||
return nil, response.ErrConflict(40996, "publish_record_history_locked", "completed publish records are kept for retry and audit")
|
||||
}
|
||||
|
||||
if tag, err := tx.Exec(ctx, `
|
||||
UPDATE publish_records
|
||||
SET deleted_at = NOW(),
|
||||
@@ -487,30 +544,12 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
||||
WHERE id = $1
|
||||
AND tenant_id = $2
|
||||
AND deleted_at IS NULL
|
||||
`, recordID, actor.TenantID); err != nil {
|
||||
`, recordID, owner.TenantID); err != nil {
|
||||
return nil, response.ErrInternal(50044, "publish_record_delete_failed", "failed to delete publish record")
|
||||
} else if tag.RowsAffected() == 0 {
|
||||
return nil, response.ErrNotFound(40435, "publish_record_not_found", "publish record not found")
|
||||
}
|
||||
|
||||
if publishRecordMayBeDiscarded(publishStatus) {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE desktop_tasks
|
||||
SET status = 'aborted',
|
||||
error = $3::jsonb,
|
||||
active_attempt_id = NULL,
|
||||
lease_token_hash = NULL,
|
||||
lease_expires_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = $1
|
||||
AND kind = 'publish'
|
||||
AND status = 'queued'
|
||||
AND payload->>'publish_record_id' = $2::text
|
||||
`, actor.TenantID, recordID, cancelErrorJSON); err != nil {
|
||||
return nil, response.ErrInternal(50044, "publish_record_delete_cancel_task_failed", "failed to cancel queued publish task before deleting record")
|
||||
}
|
||||
}
|
||||
|
||||
batchStatus, err := recalculatePublishBatchStatus(ctx, tx, publishBatchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -519,11 +558,11 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
||||
UPDATE publish_batches
|
||||
SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND tenant_id = $3
|
||||
`, batchStatus, publishBatchID, actor.TenantID); err != nil {
|
||||
`, batchStatus, publishBatchID, owner.TenantID); err != nil {
|
||||
return nil, response.ErrInternal(50054, "publish_batch_update_failed", "failed to update publish batch")
|
||||
}
|
||||
|
||||
articleStatus, err := recalculateArticlePublishStatus(ctx, tx, actor.TenantID, articleID)
|
||||
articleStatus, err := recalculateArticlePublishStatus(ctx, tx, owner.TenantID, articleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -531,16 +570,38 @@ func (s *MediaService) DeletePublishRecord(ctx context.Context, recordID int64)
|
||||
UPDATE articles
|
||||
SET publish_status = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
||||
`, articleStatus, articleID, actor.TenantID); err != nil {
|
||||
`, articleStatus, articleID, owner.TenantID); err != nil {
|
||||
return nil, response.ErrInternal(50055, "article_publish_update_failed", "failed to update article publish status")
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50044, "publish_record_delete_commit_failed", "failed to delete publish record")
|
||||
}
|
||||
|
||||
s.abortQueuedDesktopTasksForDeletedPublishRecord(ctx, owner, recordID, cancelErrorJSON)
|
||||
return &DeletePublishRecordResponse{Deleted: true}, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) abortQueuedDesktopTasksForDeletedPublishRecord(ctx context.Context, owner publishRecordDeleteOwner, recordID int64, errorJSON string) {
|
||||
if s == nil || s.pool == nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.pool.Exec(ctx, `
|
||||
UPDATE desktop_tasks
|
||||
SET status = 'aborted',
|
||||
error = $3::jsonb,
|
||||
active_attempt_id = NULL,
|
||||
lease_token_hash = NULL,
|
||||
lease_expires_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = $1
|
||||
AND workspace_id = $4
|
||||
AND kind = 'publish'
|
||||
AND status = 'queued'
|
||||
AND payload->>'publish_record_id' = $2::text
|
||||
`, owner.TenantID, recordID, errorJSON, owner.WorkspaceID)
|
||||
}
|
||||
|
||||
func publishBatchRequiresCover(accounts []platformAccountSeed) bool {
|
||||
for _, account := range accounts {
|
||||
if publishPlatformRequiresCover(account.PlatformID) {
|
||||
@@ -561,7 +622,7 @@ func publishPlatformRequiresCover(platformID string) bool {
|
||||
|
||||
func publishRecordMayBeDiscarded(status string) bool {
|
||||
switch strings.TrimSpace(status) {
|
||||
case "queued", "pending":
|
||||
case "queued", "pending", "publishing", "running", "failed", "publish_failed", "success", "published", "publish_success", "cancelled", "canceled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -194,30 +194,29 @@ func TestDesktopTaskToPublishStatusMapsAbortToCancelled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRecordMayBeDiscardedOnlyForQueuedRecords(t *testing.T) {
|
||||
for _, status := range []string{"queued", "pending"} {
|
||||
func TestPublishRecordMayBeDiscardedIncludesHistoryRecords(t *testing.T) {
|
||||
for _, status := range []string{"queued", "pending", "publishing", "running", "failed", "success", "cancelled"} {
|
||||
if !publishRecordMayBeDiscarded(status) {
|
||||
t.Fatalf("publishRecordMayBeDiscarded(%q) = false, want true", status)
|
||||
}
|
||||
}
|
||||
|
||||
for _, status := range []string{"publishing", "running", "failed", "success", "cancelled"} {
|
||||
for _, status := range []string{"", "archived"} {
|
||||
if publishRecordMayBeDiscarded(status) {
|
||||
t.Fatalf("publishRecordMayBeDiscarded(%q) = true, want false", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisiblePublishRecordWhereIncludesDeletedTerminalFailures(t *testing.T) {
|
||||
func TestVisiblePublishRecordWhereExcludesDeletedRecords(t *testing.T) {
|
||||
normalized := normalizeSQLWhitespace(visiblePublishRecordWhereSQL)
|
||||
if !strings.Contains(normalized, "pr.deleted_at IS NULL") {
|
||||
t.Fatalf("visible publish record filter must include active records: %s", normalized)
|
||||
}
|
||||
if !strings.Contains(normalized, "'failed'") || !strings.Contains(normalized, "'publish_failed'") {
|
||||
t.Fatalf("visible publish record filter must keep terminal failed history: %s", normalized)
|
||||
}
|
||||
if strings.Contains(normalized, "'queued'") || strings.Contains(normalized, "'publishing'") {
|
||||
t.Fatalf("visible publish record filter must not resurrect deleted active records: %s", normalized)
|
||||
for _, status := range []string{"'queued'", "'publishing'", "'failed'", "'publish_failed'", "'success'", "'cancelled'"} {
|
||||
if strings.Contains(normalized, status) {
|
||||
t.Fatalf("visible publish record filter must not resurrect deleted records by status %s: %s", status, normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,3 +94,17 @@ func (h *MediaHandler) DeletePublishRecord(c *gin.Context) {
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaHandler) DeleteDesktopPublishRecord(c *gin.Context) {
|
||||
recordID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || recordID <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_publish_record_id", "publish record id must be a positive number"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.DeleteDesktopPublishRecord(c.Request.Context(), MustDesktopClient(c.Request.Context()), recordID)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
|
||||
desktopAuth.GET("/content/assets/:token", publicAssets.ServeDesktop)
|
||||
desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish)
|
||||
desktopAuth.DELETE("/publish-records/:id", mediaHandler.DeleteDesktopPublishRecord)
|
||||
desktopAuth.POST("/accounts/health-reports", desktopAccountHandler.ReportHealth)
|
||||
desktopAuth.POST("/accounts", desktopAccountHandler.Upsert)
|
||||
desktopAuth.PATCH("/accounts/:id", desktopAccountHandler.Patch)
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestDesktopClientRoutes_ClientTokenAuth(t *testing.T) {
|
||||
{http.MethodGet, "/api/desktop/dispatch"},
|
||||
{http.MethodGet, "/api/desktop/accounts"},
|
||||
{http.MethodGet, "/api/desktop/publish-tasks"},
|
||||
{http.MethodDelete, "/api/desktop/publish-records/:id"},
|
||||
{http.MethodGet, "/api/desktop/content/assets/:token"},
|
||||
{http.MethodPost, "/api/desktop/clients/offline"},
|
||||
{http.MethodPost, "/api/desktop/tasks/lease"},
|
||||
|
||||
Reference in New Issue
Block a user