feat(desktop): implement workspace foundation + desktop-client skeleton

Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend
JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant,
thread workspace_id through tenant monitoring quota.

Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/
preload/renderer, shared Vue component package (packages/ui-shared), and server
surface — desktop client registration + token rotation + heartbeat, SSE task
event stream, desktop accounts/tasks/content handlers, publish job endpoint,
and supporting repositories, services, sqlc queries, and migrations.

Hard cutover per plan: remove browser-extension monitoring callback endpoints,
stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
This commit is contained in:
2026-04-19 14:18:20 +08:00
parent 98f9e95875
commit b16e9f0bd1
141 changed files with 21533 additions and 357 deletions
@@ -0,0 +1,113 @@
package transport
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"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/app"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type DesktopAccountHandler struct {
svc *app.DesktopAccountService
}
func NewDesktopAccountHandler(a *bootstrap.App) *DesktopAccountHandler {
return &DesktopAccountHandler{
svc: app.NewDesktopAccountService(
repository.NewDesktopAccountRepository(a.DB),
),
}
}
func (h *DesktopAccountHandler) List(c *gin.Context) {
if clientQuery := c.Query("client"); clientQuery != "" && clientQuery != "self" {
response.Error(c, response.ErrBadRequest(40081, "invalid_client_query", "client query must be self"))
return
}
data, err := h.svc.ListByClient(c.Request.Context(), MustDesktopClient(c.Request.Context()))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopAccountHandler) Upsert(c *gin.Context) {
var req app.UpsertDesktopAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Upsert(c.Request.Context(), MustDesktopClient(c.Request.Context()), req)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, 201, data)
}
func (h *DesktopAccountHandler) Patch(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40082, "invalid_desktop_account_id", "desktop account id must be a uuid"))
return
}
var req app.PatchDesktopAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Patch(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopAccountHandler) Delete(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40082, "invalid_desktop_account_id", "desktop account id must be a uuid"))
return
}
ifSyncVersion, err := strconv.ParseInt(c.Query("if_sync_version"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40083, "invalid_sync_version", "if_sync_version must be an integer"))
return
}
data, err := h.svc.Tombstone(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, ifSyncVersion)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopAccountHandler) RequestDelete(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40082, "invalid_desktop_account_id", "desktop account id must be a uuid"))
return
}
actor := auth.MustActor(c.Request.Context())
data, err := h.svc.RequestDelete(c.Request.Context(), actor, desktopID, c.Query("undo") == "true")
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
@@ -0,0 +1,61 @@
package transport
import (
"context"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/app"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type desktopClientKey struct{}
func DesktopClientMiddleware(repo repository.DesktopClientRepository) gin.HandlerFunc {
return func(c *gin.Context) {
token, err := bearerClientToken(c.GetHeader("Authorization"))
if err != nil {
response.Error(c, response.ErrUnauthorized(40130, "missing_client_token", "desktop client token is required"))
c.Abort()
return
}
client, err := repo.GetByTokenHash(c.Request.Context(), app.HashDesktopClientToken(token))
if err != nil {
response.Error(c, response.ErrUnauthorized(40131, "invalid_client_token", "desktop client token is invalid or revoked"))
c.Abort()
return
}
ctx := WithDesktopClient(c.Request.Context(), client)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func WithDesktopClient(ctx context.Context, client *repository.DesktopClient) context.Context {
return context.WithValue(ctx, desktopClientKey{}, client)
}
func DesktopClientFromCtx(ctx context.Context) (*repository.DesktopClient, bool) {
client, ok := ctx.Value(desktopClientKey{}).(*repository.DesktopClient)
return client, ok && client != nil
}
func MustDesktopClient(ctx context.Context) *repository.DesktopClient {
client, ok := DesktopClientFromCtx(ctx)
if !ok {
panic("desktop client not in context")
}
return client
}
func bearerClientToken(header string) (string, error) {
parts := strings.SplitN(strings.TrimSpace(header), " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || strings.TrimSpace(parts[1]) == "" {
return "", response.ErrUnauthorized(40130, "missing_client_token", "desktop client token is required")
}
return strings.TrimSpace(parts[1]), nil
}
@@ -0,0 +1,91 @@
package transport
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type stubDesktopClientRepo struct {
client *repository.DesktopClient
}
func (s *stubDesktopClientRepo) Register(ctx context.Context, params repository.RegisterDesktopClientParams) (*repository.DesktopClient, error) {
panic("unexpected call")
}
func (s *stubDesktopClientRepo) GetByTokenHash(ctx context.Context, tokenHash []byte) (*repository.DesktopClient, error) {
if s.client == nil {
return nil, context.Canceled
}
return s.client, nil
}
func (s *stubDesktopClientRepo) RotateToken(ctx context.Context, params repository.RotateDesktopClientTokenParams) (*repository.DesktopClient, error) {
panic("unexpected call")
}
func (s *stubDesktopClientRepo) Heartbeat(ctx context.Context, params repository.HeartbeatDesktopClientParams) (*repository.DesktopClient, error) {
panic("unexpected call")
}
func (s *stubDesktopClientRepo) Revoke(ctx context.Context, id uuid.UUID, workspaceID int64) (*repository.DesktopClient, error) {
panic("unexpected call")
}
func (s *stubDesktopClientRepo) ListByWorkspace(ctx context.Context, workspaceID int64) ([]repository.DesktopClient, error) {
panic("unexpected call")
}
func TestDesktopClientMiddleware_RequiresBearerToken(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(DesktopClientMiddleware(&stubDesktopClientRepo{}))
router.POST("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, "/test", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusUnauthorized, resp.Code)
}
func TestDesktopClientMiddleware_SetsClientContext(t *testing.T) {
gin.SetMode(gin.TestMode)
expectedID := uuid.New()
router := gin.New()
router.Use(DesktopClientMiddleware(&stubDesktopClientRepo{
client: &repository.DesktopClient{
ID: expectedID,
TenantID: 1,
WorkspaceID: 2,
UserID: 3,
CreatedAt: time.Now(),
},
}))
var gotID uuid.UUID
router.POST("/test", func(c *gin.Context) {
client := MustDesktopClient(c.Request.Context())
gotID = client.ID
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Header.Set("Authorization", "Bearer client-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, expectedID, gotID)
}
@@ -0,0 +1,73 @@
package transport
import (
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"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/app"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type DesktopClientHandler struct {
svc *app.DesktopClientService
}
func NewDesktopClientHandler(a *bootstrap.App) *DesktopClientHandler {
return &DesktopClientHandler{
svc: app.NewDesktopClientService(
repository.NewDesktopClientRepository(a.DB),
),
}
}
func (h *DesktopClientHandler) Register(c *gin.Context) {
actor := auth.MustActor(c.Request.Context())
var req app.RegisterDesktopClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Register(c.Request.Context(), actor, req)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, 201, data)
}
func (h *DesktopClientHandler) Rotate(c *gin.Context) {
data, err := h.svc.Rotate(c.Request.Context(), MustDesktopClient(c.Request.Context()))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopClientHandler) Heartbeat(c *gin.Context) {
var req app.HeartbeatDesktopClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Heartbeat(c.Request.Context(), MustDesktopClient(c.Request.Context()), req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopClientHandler) Revoke(c *gin.Context) {
data, err := h.svc.Revoke(c.Request.Context(), MustDesktopClient(c.Request.Context()))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
@@ -0,0 +1,42 @@
package transport
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/app"
)
type DesktopContentHandler struct {
svc *app.DesktopContentService
}
func NewDesktopContentHandler(a *bootstrap.App) *DesktopContentHandler {
return &DesktopContentHandler{
svc: app.NewDesktopContentService(
a.DB,
a.AuditLogs,
a.ObjectStorage,
a.Cache,
),
}
}
func (h *DesktopContentHandler) Article(c *gin.Context) {
articleID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || articleID <= 0 {
response.Error(c, response.ErrBadRequest(40095, "invalid_article_id", "article id must be a positive integer"))
return
}
data, svcErr := h.svc.Article(c.Request.Context(), MustDesktopClient(c.Request.Context()), articleID)
if svcErr != nil {
response.Error(c, svcErr)
return
}
response.Success(c, data)
}
@@ -0,0 +1,66 @@
package transport
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
)
type DesktopEventsHandler struct {
streams *bootstrap.App
}
func NewDesktopEventsHandler(a *bootstrap.App) *DesktopEventsHandler {
return &DesktopEventsHandler{streams: a}
}
func (h *DesktopEventsHandler) Stream(c *gin.Context) {
client := MustDesktopClient(c.Request.Context())
events, cancel := h.streams.DesktopTaskStreams.Subscribe(client.ID.String())
defer cancel()
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
return
}
if err := writeSSE(c, "connected", gin.H{
"client_id": client.ID.String(),
"workspace_id": client.WorkspaceID,
"server_time": time.Now().UTC(),
}); err != nil {
return
}
flusher.Flush()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-c.Request.Context().Done():
return
case <-heartbeat.C:
if err := writeSSE(c, "ping", gin.H{"ts": time.Now().UTC()}); err != nil {
return
}
flusher.Flush()
case event, ok := <-events:
if !ok {
return
}
if err := writeSSE(c, event.Type, event); err != nil {
return
}
flusher.Flush()
}
}
}
@@ -0,0 +1,180 @@
package transport
import (
"errors"
"io"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"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/app"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type DesktopTaskHandler struct {
svc *app.DesktopTaskService
}
func NewDesktopTaskHandler(a *bootstrap.App) *DesktopTaskHandler {
return &DesktopTaskHandler{
svc: app.NewDesktopTaskService(
repository.NewDesktopTaskRepository(a.DB),
a.RabbitMQ,
a.Logger,
),
}
}
func (h *DesktopTaskHandler) Lease(c *gin.Context) {
var req app.LeaseDesktopTaskRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
var routeTaskID *uuid.UUID
if rawID := c.Param("id"); rawID != "" {
parsed, err := uuid.Parse(rawID)
if err != nil {
response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid"))
return
}
routeTaskID = &parsed
}
data, err := h.svc.Lease(c.Request.Context(), MustDesktopClient(c.Request.Context()), req, routeTaskID, c.Query("from_parked") == "true")
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopTaskHandler) Extend(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid"))
return
}
var req app.ExtendDesktopTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Extend(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopTaskHandler) Park(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid"))
return
}
var req app.ParkDesktopTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Park(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopTaskHandler) Result(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid"))
return
}
var req app.CompleteDesktopTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Complete(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopTaskHandler) Cancel(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid"))
return
}
var req app.CancelDesktopTaskRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Cancel(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopTaskHandler) TenantCancel(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid"))
return
}
var req app.CancelDesktopTaskRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.TenantCancel(c.Request.Context(), auth.MustActor(c.Request.Context()), desktopID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopTaskHandler) Reconcile(c *gin.Context) {
desktopID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid"))
return
}
var req app.ReconcileDesktopTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Reconcile(c.Request.Context(), auth.MustActor(c.Request.Context()), desktopID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
@@ -1,162 +0,0 @@
package transport
import (
"errors"
"io"
"strconv"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/app"
)
type MonitoringCallbackHandler struct {
svc *app.MonitoringCallbackService
}
func NewMonitoringCallbackHandler(a *bootstrap.App) *MonitoringCallbackHandler {
return &MonitoringCallbackHandler{
svc: app.NewMonitoringCallbackService(a.DB, a.MonitoringDB, a.RabbitMQ, a.LLM, a.Logger),
}
}
func (h *MonitoringCallbackHandler) Heartbeat(c *gin.Context) {
installationID, installationToken, ok := parseMonitoringInstallationHeaders(c)
if !ok {
return
}
var req app.MonitoringHeartbeatRequest
if err := bindOptionalJSON(c, &req); err != nil {
response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error()))
return
}
data, svcErr := h.svc.Heartbeat(c.Request.Context(), installationID, installationToken, req)
if svcErr != nil {
response.Error(c, svcErr)
return
}
response.Success(c, data)
}
func (h *MonitoringCallbackHandler) LeaseTasks(c *gin.Context) {
installationID, installationToken, ok := parseMonitoringInstallationHeaders(c)
if !ok {
return
}
var req app.MonitoringLeaseTasksRequest
if err := bindOptionalJSON(c, &req); err != nil {
response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error()))
return
}
data, svcErr := h.svc.LeaseTasks(c.Request.Context(), installationID, installationToken, req)
if svcErr != nil {
response.Error(c, svcErr)
return
}
response.Success(c, data)
}
func (h *MonitoringCallbackHandler) ResumeTasks(c *gin.Context) {
installationID, installationToken, ok := parseMonitoringInstallationHeaders(c)
if !ok {
return
}
var req app.MonitoringResumeTasksRequest
if err := bindOptionalJSON(c, &req); err != nil {
response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error()))
return
}
data, svcErr := h.svc.ResumeTasks(c.Request.Context(), installationID, installationToken, req)
if svcErr != nil {
response.Error(c, svcErr)
return
}
response.Success(c, data)
}
func (h *MonitoringCallbackHandler) TaskResult(c *gin.Context) {
taskID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40041, "invalid_task_id", "task id must be a number"))
return
}
installationID, installationToken, ok := parseMonitoringInstallationHeaders(c)
if !ok {
return
}
var req app.MonitoringTaskResultRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error()))
return
}
data, svcErr := h.svc.HandleTaskResult(c.Request.Context(), installationID, installationToken, taskID, req)
if svcErr != nil {
response.Error(c, svcErr)
return
}
response.Success(c, data)
}
func (h *MonitoringCallbackHandler) SkipTask(c *gin.Context) {
taskID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40041, "invalid_task_id", "task id must be a number"))
return
}
installationID, installationToken, ok := parseMonitoringInstallationHeaders(c)
if !ok {
return
}
var req app.MonitoringSkipTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error()))
return
}
data, svcErr := h.svc.SkipTask(c.Request.Context(), installationID, installationToken, taskID, req)
if svcErr != nil {
response.Error(c, svcErr)
return
}
response.Success(c, data)
}
func parseMonitoringInstallationHeaders(c *gin.Context) (int64, string, bool) {
installationID, err := strconv.ParseInt(c.GetHeader("X-Geo-Installation-Id"), 10, 64)
if err != nil {
response.Error(c, response.ErrUnauthorized(40141, "invalid_installation_id", "installation id header is invalid"))
return 0, "", false
}
installationToken := c.GetHeader("X-Geo-Installation-Token")
if installationToken == "" {
response.Error(c, response.ErrUnauthorized(40141, "missing_installation_token", "installation token header is required"))
return 0, "", false
}
return installationID, installationToken, true
}
func bindOptionalJSON(c *gin.Context, target interface{}) error {
err := c.ShouldBindJSON(target)
if err == nil {
return nil
}
if errors.Is(err, io.EOF) {
return nil
}
return err
}
@@ -0,0 +1,35 @@
package transport
import (
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"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/app"
)
type PublishJobHandler struct {
svc *app.PublishJobService
}
func NewPublishJobHandler(a *bootstrap.App) *PublishJobHandler {
return &PublishJobHandler{
svc: app.NewPublishJobService(a.DB, a.RabbitMQ, a.Logger),
}
}
func (h *PublishJobHandler) Create(c *gin.Context) {
var req app.CreatePublishJobRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Create(c.Request.Context(), auth.MustActor(c.Request.Context()), req)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, 201, data)
}
+30 -15
View File
@@ -21,14 +21,6 @@ func RegisterRoutes(app *bootstrap.App) {
callbacks.POST("/bind", pluginHandler.PluginBindCallback)
callbacks.POST("/publish", pluginHandler.PluginPublishCallback)
monitoringCallbackHandler := NewMonitoringCallbackHandler(app)
monitoringPlugin := app.Engine.Group("/api/plugin/monitoring")
monitoringPlugin.POST("/heartbeat", monitoringCallbackHandler.Heartbeat)
monitoringPlugin.POST("/tasks/lease", monitoringCallbackHandler.LeaseTasks)
monitoringPlugin.POST("/tasks/resume", monitoringCallbackHandler.ResumeTasks)
monitoringPlugin.POST("/tasks/:id/result", monitoringCallbackHandler.TaskResult)
monitoringPlugin.POST("/tasks/:id/skip", monitoringCallbackHandler.SkipTask)
protected := app.Engine.Group("/api")
protected.Use(auth.Middleware(app.JWT, app.Sessions))
protected.Use(middleware.TenantScope())
@@ -36,8 +28,38 @@ func RegisterRoutes(app *bootstrap.App) {
protected.GET("/auth/me", authHandler.Me)
protected.POST("/auth/logout", authHandler.Logout)
desktopClientHandler := NewDesktopClientHandler(app)
desktopAccountHandler := NewDesktopAccountHandler(app)
desktopTaskHandler := NewDesktopTaskHandler(app)
desktopEventsHandler := NewDesktopEventsHandler(app)
desktopContentHandler := NewDesktopContentHandler(app)
publishJobHandler := NewPublishJobHandler(app)
protected.POST("/desktop/clients/register", desktopClientHandler.Register)
desktopAuth := app.Engine.Group("/api/desktop")
desktopAuth.Use(DesktopClientMiddleware(repository.NewDesktopClientRepository(app.DB)))
desktopAuth.POST("/clients/rotate", desktopClientHandler.Rotate)
desktopAuth.POST("/clients/heartbeat", desktopClientHandler.Heartbeat)
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
desktopAuth.GET("/events", desktopEventsHandler.Stream)
desktopAuth.GET("/accounts", desktopAccountHandler.List)
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
desktopAuth.POST("/accounts", desktopAccountHandler.Upsert)
desktopAuth.PATCH("/accounts/:id", desktopAccountHandler.Patch)
desktopAuth.DELETE("/accounts/:id", desktopAccountHandler.Delete)
desktopAuth.POST("/tasks/lease", desktopTaskHandler.Lease)
desktopAuth.POST("/tasks/:id/lease", desktopTaskHandler.Lease)
desktopAuth.POST("/tasks/:id/extend", desktopTaskHandler.Extend)
desktopAuth.POST("/tasks/:id/park", desktopTaskHandler.Park)
desktopAuth.POST("/tasks/:id/cancel", desktopTaskHandler.Cancel)
desktopAuth.POST("/tasks/:id/result", desktopTaskHandler.Result)
tenantProtected := protected.Group("/tenant")
tenantProtected.Use(RequireActiveTenantSubscription(repository.NewTenantPlanRepository(app.DB)))
tenantProtected.POST("/accounts/:id/request-delete", desktopAccountHandler.RequestDelete)
tenantProtected.POST("/tasks/:id/reconcile", desktopTaskHandler.Reconcile)
tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel)
tenantProtected.POST("/publish-jobs", publishJobHandler.Create)
workspace := tenantProtected.Group("/workspace")
wsHandler := NewWorkspaceHandler(app)
@@ -125,13 +147,6 @@ func RegisterRoutes(app *bootstrap.App) {
articles.POST("/:id/publish-batches", pluginHandler.CreatePublishBatch)
articles.DELETE("/:id", artHandler.Delete)
media := tenantProtected.Group("/media")
media.GET("/platforms", pluginHandler.ListPlatforms)
media.GET("/platform-accounts", pluginHandler.ListPlatformAccounts)
media.POST("/plugin-installations/register", pluginHandler.RegisterPluginInstallation)
media.POST("/plugin-sessions", pluginHandler.CreatePluginSession)
media.DELETE("/platform-accounts/:id", pluginHandler.DeletePlatformAccount)
brands := tenantProtected.Group("/brands")
brandHandler := NewBrandHandler(app)
brands.GET("", brandHandler.List)
@@ -25,6 +25,11 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) {
}{
{http.MethodGet, "/api/auth/me"},
{http.MethodPost, "/api/auth/logout"},
{http.MethodPost, "/api/desktop/clients/register"},
{http.MethodPost, "/api/tenant/accounts/:id/request-delete"},
{http.MethodPost, "/api/tenant/tasks/:id/reconcile"},
{http.MethodPost, "/api/tenant/tasks/:id/cancel"},
{http.MethodPost, "/api/tenant/publish-jobs"},
{http.MethodGet, "/api/tenant/workspace/overview"},
{http.MethodGet, "/api/tenant/templates"},
{http.MethodGet, "/api/tenant/articles"},
@@ -52,11 +57,6 @@ func TestPublicRoutes_NoAuth(t *testing.T) {
}{
{http.MethodPost, "/api/auth/login"},
{http.MethodPost, "/api/auth/refresh"},
{http.MethodPost, "/api/plugin/monitoring/heartbeat"},
{http.MethodPost, "/api/plugin/monitoring/tasks/lease"},
{http.MethodPost, "/api/plugin/monitoring/tasks/resume"},
{http.MethodPost, "/api/plugin/monitoring/tasks/:id/result"},
{http.MethodPost, "/api/plugin/monitoring/tasks/:id/skip"},
{http.MethodGet, "/api/health/live"},
{http.MethodGet, "/api/health/ready"},
}
@@ -68,6 +68,24 @@ func TestPublicRoutes_NoAuth(t *testing.T) {
}
}
func TestDesktopClientRoutes_ClientTokenAuth(t *testing.T) {
routes := []struct {
method string
path string
}{
{http.MethodGet, "/api/desktop/events"},
{http.MethodGet, "/api/desktop/accounts"},
{http.MethodPost, "/api/desktop/tasks/lease"},
{http.MethodPost, "/api/desktop/tasks/:id/result"},
}
for _, rt := range routes {
t.Run(rt.method+" "+rt.path, func(t *testing.T) {
assert.NotEmpty(t, rt.path)
})
}
}
func TestHealthEndpoint_Live(t *testing.T) {
r := gin.New()
r.GET("/api/health/live", func(c *gin.Context) {