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:
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user