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:
@@ -49,15 +49,16 @@ type LoginResponse struct {
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
TenantRole string `json:"tenant_role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Membership *MembershipInfo `json:"membership"`
|
||||
KolProfile *KolProfileBrief `json:"kol_profile"`
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
PrimaryWorkspaceID int64 `json:"primary_workspace_id"`
|
||||
TenantRole string `json:"tenant_role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Membership *MembershipInfo `json:"membership"`
|
||||
KolProfile *KolProfileBrief `json:"kol_profile"`
|
||||
}
|
||||
|
||||
type MembershipInfo struct {
|
||||
@@ -97,7 +98,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
return nil, response.ErrForbidden(40301, "no_tenant", "user has no active tenant membership")
|
||||
}
|
||||
|
||||
pair, err := s.jwt.Issue(user.ID, membership.TenantID, membership.TenantRole)
|
||||
pair, err := s.jwt.Issue(user.ID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue token: %w", err)
|
||||
}
|
||||
@@ -121,15 +122,16 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
RefreshToken: pair.RefreshToken,
|
||||
ExpiresAt: pair.ExpiresAt,
|
||||
User: UserInfo{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
AvatarURL: user.AvatarURL,
|
||||
TenantID: membership.TenantID,
|
||||
TenantRole: membership.TenantRole,
|
||||
Permissions: auth.PermissionsForRole(membership.TenantRole),
|
||||
Membership: memberInfo,
|
||||
KolProfile: kolProfile,
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
AvatarURL: user.AvatarURL,
|
||||
TenantID: membership.TenantID,
|
||||
PrimaryWorkspaceID: membership.PrimaryWorkspaceID,
|
||||
TenantRole: membership.TenantRole,
|
||||
Permissions: auth.PermissionsForRole(membership.TenantRole),
|
||||
Membership: memberInfo,
|
||||
KolProfile: kolProfile,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -150,7 +152,7 @@ func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*Refresh
|
||||
return nil, response.ErrUnauthorized(40120, "invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
|
||||
pair, err := s.jwt.Issue(claims.UserID, claims.TenantID, claims.Role)
|
||||
pair, err := s.jwt.Issue(claims.UserID, claims.TenantID, claims.PrimaryWorkspaceID, claims.Role)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue token: %w", err)
|
||||
}
|
||||
@@ -196,15 +198,16 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
AvatarURL: user.AvatarURL,
|
||||
TenantID: actor.TenantID,
|
||||
TenantRole: actor.Role,
|
||||
Permissions: auth.PermissionsForRole(actor.Role),
|
||||
Membership: memberInfo,
|
||||
KolProfile: kolProfile,
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
AvatarURL: user.AvatarURL,
|
||||
TenantID: actor.TenantID,
|
||||
PrimaryWorkspaceID: actor.PrimaryWorkspaceID,
|
||||
TenantRole: actor.Role,
|
||||
Permissions: auth.PermissionsForRole(actor.Role),
|
||||
Membership: memberInfo,
|
||||
KolProfile: kolProfile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"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 DesktopAccountService struct {
|
||||
repo repository.DesktopAccountRepository
|
||||
}
|
||||
|
||||
func NewDesktopAccountService(repo repository.DesktopAccountRepository) *DesktopAccountService {
|
||||
return &DesktopAccountService{repo: repo}
|
||||
}
|
||||
|
||||
type DesktopAccountView struct {
|
||||
ID string `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformUID string `json:"platform_uid"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Health string `json:"health"`
|
||||
ClientID *string `json:"client_id"`
|
||||
AccountFingerprint *string `json:"account_fingerprint"`
|
||||
VerifiedAt *time.Time `json:"verified_at"`
|
||||
Tags []string `json:"tags"`
|
||||
SyncVersion int64 `json:"sync_version"`
|
||||
DeletedAt *time.Time `json:"deleted_at"`
|
||||
DeleteRequestedAt *time.Time `json:"delete_requested_at"`
|
||||
}
|
||||
|
||||
type UpsertDesktopAccountRequest struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
PlatformUID string `json:"platform_uid" binding:"required"`
|
||||
AccountFingerprint *string `json:"account_fingerprint"`
|
||||
DisplayName string `json:"display_name" binding:"required"`
|
||||
Health string `json:"health" binding:"required,oneof=live expired captcha risk"`
|
||||
VerifiedAt *time.Time `json:"verified_at"`
|
||||
Tags []string `json:"tags"`
|
||||
IfSyncVersion *int64 `json:"if_sync_version"`
|
||||
}
|
||||
|
||||
type PatchDesktopAccountRequest struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Health *string `json:"health" binding:"omitempty,oneof=live expired captcha risk"`
|
||||
VerifiedAt *time.Time `json:"verified_at"`
|
||||
Tags *[]string `json:"tags"`
|
||||
IfSyncVersion int64 `json:"if_sync_version" binding:"required"`
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) ListByClient(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
rows, err := s.repo.ListByClient(ctx, client.WorkspaceID, client.ID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50081, "desktop_accounts_query_failed", "failed to list desktop accounts")
|
||||
}
|
||||
|
||||
items := make([]DesktopAccountView, 0, len(rows))
|
||||
for _, item := range rows {
|
||||
items = append(items, buildDesktopAccountView(&item))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) Upsert(ctx context.Context, client *repository.DesktopClient, req UpsertDesktopAccountRequest) (*DesktopAccountView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
if req.IfSyncVersion != nil {
|
||||
existing, err := s.repo.GetByIdentity(ctx, client.WorkspaceID, req.Platform, req.PlatformUID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrInternal(50082, "desktop_account_lookup_failed", "failed to inspect desktop account before upsert")
|
||||
}
|
||||
if existing == nil || errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40981, "desktop_account_sync_version_required_existing", "if_sync_version is only valid when account already exists")
|
||||
}
|
||||
}
|
||||
|
||||
account, err := s.repo.Upsert(ctx, repository.UpsertDesktopAccountParams{
|
||||
DesktopID: uuid.New(),
|
||||
TenantID: client.TenantID,
|
||||
WorkspaceID: client.WorkspaceID,
|
||||
UserID: client.UserID,
|
||||
ClientID: client.ID,
|
||||
Platform: req.Platform,
|
||||
PlatformUID: req.PlatformUID,
|
||||
AccountFingerprint: req.AccountFingerprint,
|
||||
DisplayName: req.DisplayName,
|
||||
Health: req.Health,
|
||||
VerifiedAt: req.VerifiedAt,
|
||||
Tags: req.Tags,
|
||||
IfSyncVersion: req.IfSyncVersion,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict")
|
||||
}
|
||||
return nil, response.ErrInternal(50083, "desktop_account_upsert_failed", "failed to upsert desktop account")
|
||||
}
|
||||
|
||||
view := buildDesktopAccountView(account)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) Patch(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req PatchDesktopAccountRequest) (*DesktopAccountView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
account, err := s.repo.Patch(ctx, repository.PatchDesktopAccountParams{
|
||||
DesktopID: desktopID,
|
||||
WorkspaceID: client.WorkspaceID,
|
||||
DisplayName: req.DisplayName,
|
||||
Health: req.Health,
|
||||
VerifiedAt: req.VerifiedAt,
|
||||
Tags: req.Tags,
|
||||
IfSyncVersion: req.IfSyncVersion,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict")
|
||||
}
|
||||
return nil, response.ErrInternal(50084, "desktop_account_patch_failed", "failed to patch desktop account")
|
||||
}
|
||||
|
||||
view := buildDesktopAccountView(account)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) Tombstone(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, ifSyncVersion int64) (*DesktopAccountView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
account, err := s.repo.Tombstone(ctx, desktopID, client.WorkspaceID, ifSyncVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict")
|
||||
}
|
||||
return nil, response.ErrInternal(50085, "desktop_account_delete_failed", "failed to delete desktop account")
|
||||
}
|
||||
|
||||
view := buildDesktopAccountView(account)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, undo bool) (*DesktopAccountView, error) {
|
||||
if actor.PrimaryWorkspaceID == 0 {
|
||||
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
||||
}
|
||||
|
||||
var (
|
||||
account *repository.DesktopAccount
|
||||
err error
|
||||
)
|
||||
|
||||
if undo {
|
||||
account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID)
|
||||
} else {
|
||||
account, err = s.repo.MarkDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrNotFound(40481, "desktop_account_not_found", "desktop account not found")
|
||||
}
|
||||
return nil, response.ErrInternal(50086, "desktop_account_request_delete_failed", "failed to update desktop account delete request")
|
||||
}
|
||||
|
||||
view := buildDesktopAccountView(account)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func buildDesktopAccountView(account *repository.DesktopAccount) DesktopAccountView {
|
||||
var clientID *string
|
||||
if account.ClientID != nil {
|
||||
value := account.ClientID.String()
|
||||
clientID = &value
|
||||
}
|
||||
|
||||
return DesktopAccountView{
|
||||
ID: account.DesktopID.String(),
|
||||
Platform: account.Platform,
|
||||
PlatformUID: account.PlatformUID,
|
||||
DisplayName: account.DisplayName,
|
||||
Health: account.Health,
|
||||
ClientID: clientID,
|
||||
AccountFingerprint: account.AccountFingerprint,
|
||||
VerifiedAt: account.VerifiedAt,
|
||||
Tags: account.Tags,
|
||||
SyncVersion: account.SyncVersion,
|
||||
DeletedAt: account.DeletedAt,
|
||||
DeleteRequestedAt: account.DeleteRequestedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const DesktopClientTokenTTL = 30 * 24 * time.Hour
|
||||
|
||||
type DesktopClientService struct {
|
||||
repo repository.DesktopClientRepository
|
||||
}
|
||||
|
||||
func NewDesktopClientService(repo repository.DesktopClientRepository) *DesktopClientService {
|
||||
return &DesktopClientService{repo: repo}
|
||||
}
|
||||
|
||||
type RegisterDesktopClientRequest struct {
|
||||
DeviceName string `json:"device_name" binding:"required"`
|
||||
OS string `json:"os" binding:"required"`
|
||||
CPUArch string `json:"cpu_arch"`
|
||||
ClientVersion string `json:"client_version" binding:"required"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
type HeartbeatDesktopClientRequest struct {
|
||||
DeviceName *string `json:"device_name"`
|
||||
OS *string `json:"os"`
|
||||
CPUArch *string `json:"cpu_arch"`
|
||||
ClientVersion *string `json:"client_version"`
|
||||
Channel *string `json:"channel"`
|
||||
}
|
||||
|
||||
type DesktopClientView struct {
|
||||
ID string `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
DeviceName *string `json:"device_name"`
|
||||
OS *string `json:"os"`
|
||||
CPUArch *string `json:"cpu_arch"`
|
||||
ClientVersion *string `json:"client_version"`
|
||||
Channel *string `json:"channel"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||
LastRotatedAt *time.Time `json:"last_rotated_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
}
|
||||
|
||||
type RegisterDesktopClientResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientToken string `json:"client_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Client DesktopClientView `json:"client"`
|
||||
}
|
||||
|
||||
type RotateDesktopClientResponse struct {
|
||||
ClientToken string `json:"client_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Client DesktopClientView `json:"client"`
|
||||
}
|
||||
|
||||
type HeartbeatDesktopClientResponse struct {
|
||||
Client DesktopClientView `json:"client"`
|
||||
ServerTime time.Time `json:"server_time"`
|
||||
}
|
||||
|
||||
func HashDesktopClientToken(raw string) []byte {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func (s *DesktopClientService) Register(ctx context.Context, actor auth.Actor, req RegisterDesktopClientRequest) (*RegisterDesktopClientResponse, error) {
|
||||
if actor.TenantID == 0 || actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 {
|
||||
return nil, response.ErrUnauthorized(40107, "desktop_register_requires_actor", "desktop client registration requires authenticated workspace context")
|
||||
}
|
||||
|
||||
rawToken, tokenHash, err := newDesktopClientToken()
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50071, "desktop_client_token_failed", "failed to generate desktop client token")
|
||||
}
|
||||
|
||||
client, err := s.repo.Register(ctx, repository.RegisterDesktopClientParams{
|
||||
ID: uuid.New(),
|
||||
TenantID: actor.TenantID,
|
||||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||||
UserID: actor.UserID,
|
||||
TokenHash: tokenHash,
|
||||
DeviceName: requiredStringPtr(req.DeviceName),
|
||||
OS: requiredStringPtr(req.OS),
|
||||
CPUArch: trimmedStringPtr(req.CPUArch),
|
||||
ClientVersion: requiredStringPtr(req.ClientVersion),
|
||||
Channel: trimmedStringPtr(req.Channel),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50072, "desktop_client_register_failed", "failed to persist desktop client")
|
||||
}
|
||||
|
||||
return &RegisterDesktopClientResponse{
|
||||
ClientID: client.ID.String(),
|
||||
ClientToken: rawToken,
|
||||
ExpiresAt: time.Now().Add(DesktopClientTokenTTL).Unix(),
|
||||
Client: buildDesktopClientView(client),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientService) Rotate(ctx context.Context, client *repository.DesktopClient) (*RotateDesktopClientResponse, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
rawToken, tokenHash, err := newDesktopClientToken()
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50073, "desktop_client_rotate_token_failed", "failed to generate next desktop client token")
|
||||
}
|
||||
|
||||
updated, err := s.repo.RotateToken(ctx, repository.RotateDesktopClientTokenParams{
|
||||
ID: client.ID,
|
||||
WorkspaceID: client.WorkspaceID,
|
||||
TokenHash: tokenHash,
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, response.ErrUnauthorized(40109, "desktop_client_revoked", "desktop client is revoked")
|
||||
}
|
||||
return nil, response.ErrInternal(50074, "desktop_client_rotate_failed", "failed to rotate desktop client token")
|
||||
}
|
||||
|
||||
return &RotateDesktopClientResponse{
|
||||
ClientToken: rawToken,
|
||||
ExpiresAt: time.Now().Add(DesktopClientTokenTTL).Unix(),
|
||||
Client: buildDesktopClientView(updated),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientService) Heartbeat(ctx context.Context, client *repository.DesktopClient, req HeartbeatDesktopClientRequest) (*HeartbeatDesktopClientResponse, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
updated, err := s.repo.Heartbeat(ctx, repository.HeartbeatDesktopClientParams{
|
||||
ID: client.ID,
|
||||
WorkspaceID: client.WorkspaceID,
|
||||
DeviceName: req.DeviceName,
|
||||
OS: req.OS,
|
||||
CPUArch: req.CPUArch,
|
||||
ClientVersion: req.ClientVersion,
|
||||
Channel: req.Channel,
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, response.ErrUnauthorized(40109, "desktop_client_revoked", "desktop client is revoked")
|
||||
}
|
||||
return nil, response.ErrInternal(50075, "desktop_client_heartbeat_failed", "failed to persist desktop client heartbeat")
|
||||
}
|
||||
|
||||
return &HeartbeatDesktopClientResponse{
|
||||
Client: buildDesktopClientView(updated),
|
||||
ServerTime: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientService) Revoke(ctx context.Context, client *repository.DesktopClient) (*DesktopClientView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
updated, err := s.repo.Revoke(ctx, client.ID, client.WorkspaceID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, response.ErrUnauthorized(40109, "desktop_client_revoked", "desktop client is revoked")
|
||||
}
|
||||
return nil, response.ErrInternal(50076, "desktop_client_revoke_failed", "failed to revoke desktop client")
|
||||
}
|
||||
|
||||
view := buildDesktopClientView(updated)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func newDesktopClientToken() (string, []byte, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", nil, fmt.Errorf("generate desktop client token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
return token, HashDesktopClientToken(token), nil
|
||||
}
|
||||
|
||||
func buildDesktopClientView(client *repository.DesktopClient) DesktopClientView {
|
||||
return DesktopClientView{
|
||||
ID: client.ID.String(),
|
||||
TenantID: client.TenantID,
|
||||
WorkspaceID: client.WorkspaceID,
|
||||
UserID: client.UserID,
|
||||
DeviceName: client.DeviceName,
|
||||
OS: client.OS,
|
||||
CPUArch: client.CPUArch,
|
||||
ClientVersion: client.ClientVersion,
|
||||
Channel: client.Channel,
|
||||
CreatedAt: client.CreatedAt,
|
||||
LastSeenAt: client.LastSeenAt,
|
||||
LastRotatedAt: client.LastRotatedAt,
|
||||
RevokedAt: client.RevokedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func requiredStringPtr(value string) *string {
|
||||
trimmed := value
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func trimmedStringPtr(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
trimmed := value
|
||||
return &trimmed
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type DesktopContentService struct {
|
||||
articles *ArticleService
|
||||
}
|
||||
|
||||
type DesktopArticleContentView struct {
|
||||
ArticleID int64 `json:"article_id"`
|
||||
Title string `json:"title"`
|
||||
HTMLContent *string `json:"html_content"`
|
||||
MarkdownContent *string `json:"markdown_content"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
}
|
||||
|
||||
func NewDesktopContentService(
|
||||
pool *pgxpool.Pool,
|
||||
auditLogs *auditlog.AsyncWriter,
|
||||
objectStorage objectstorage.Client,
|
||||
cache sharedcache.Cache,
|
||||
) *DesktopContentService {
|
||||
return &DesktopContentService{
|
||||
articles: NewArticleService(pool, auditLogs, objectStorage).WithCache(cache),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DesktopContentService) Article(
|
||||
ctx context.Context,
|
||||
client *repository.DesktopClient,
|
||||
articleID int64,
|
||||
) (*DesktopArticleContentView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
detail, found, err := s.articles.loadArticleDetail(ctx, client.TenantID, articleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found || detail == nil {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
|
||||
title := ""
|
||||
if detail.Title != nil {
|
||||
title = *detail.Title
|
||||
}
|
||||
|
||||
return &DesktopArticleContentView{
|
||||
ArticleID: detail.ID,
|
||||
Title: title,
|
||||
HTMLContent: detail.HTMLContent,
|
||||
MarkdownContent: detail.MarkdownContent,
|
||||
CoverAssetURL: detail.CoverAssetURL,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
)
|
||||
|
||||
type DesktopTaskEvent struct {
|
||||
Type string `json:"type"`
|
||||
TaskID string `json:"task_id"`
|
||||
JobID string `json:"job_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
TargetClientID string `json:"target_client_id"`
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func publishDesktopTaskEvent(ctx context.Context, client *rabbitmq.Client, event DesktopTaskEvent) error {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return client.PublishDesktopTaskEvent(ctx, payload)
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type DesktopTaskService struct {
|
||||
repo repository.DesktopTaskRepository
|
||||
messaging *rabbitmq.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewDesktopTaskService(repo repository.DesktopTaskRepository, messaging *rabbitmq.Client, logger *zap.Logger) *DesktopTaskService {
|
||||
return &DesktopTaskService{
|
||||
repo: repo,
|
||||
messaging: messaging,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type DesktopTaskView struct {
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"job_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
TargetAccountID string `json:"target_account_id"`
|
||||
TargetClientID string `json:"target_client_id"`
|
||||
Platform string `json:"platform"`
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
DedupKey *string `json:"dedup_key"`
|
||||
ActiveAttemptID *string `json:"active_attempt_id"`
|
||||
LeaseExpiresAt *time.Time `json:"lease_expires_at"`
|
||||
Attempts int `json:"attempts"`
|
||||
ParkedReason *string `json:"parked_reason"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type LeaseDesktopTaskRequest struct {
|
||||
TaskID *string `json:"task_id"`
|
||||
Kind *string `json:"kind" binding:"omitempty,oneof=publish monitor"`
|
||||
}
|
||||
|
||||
type LeaseDesktopTaskResponse struct {
|
||||
Task *DesktopTaskView `json:"task"`
|
||||
AttemptID *string `json:"attempt_id,omitempty"`
|
||||
LeaseToken *string `json:"lease_token,omitempty"`
|
||||
LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type ExtendDesktopTaskRequest struct {
|
||||
LeaseToken string `json:"lease_token" binding:"required"`
|
||||
}
|
||||
|
||||
type ParkDesktopTaskRequest struct {
|
||||
LeaseToken string `json:"lease_token" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required,oneof=waiting_user captcha"`
|
||||
}
|
||||
|
||||
type CompleteDesktopTaskRequest struct {
|
||||
LeaseToken string `json:"lease_token" binding:"required"`
|
||||
Status string `json:"status" binding:"required,oneof=succeeded failed unknown"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Error map[string]any `json:"error"`
|
||||
}
|
||||
|
||||
type CancelDesktopTaskRequest struct {
|
||||
LeaseToken *string `json:"lease_token"`
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
type ReconcileDesktopTaskRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=succeeded failed aborted retry"`
|
||||
Result map[string]any `json:"result"`
|
||||
Error map[string]any `json:"error"`
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID, fromParked bool) (*LeaseDesktopTaskResponse, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
taskID := routeTaskID
|
||||
if taskID == nil && req.TaskID != nil && strings.TrimSpace(*req.TaskID) != "" {
|
||||
parsed, err := uuid.Parse(strings.TrimSpace(*req.TaskID))
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid")
|
||||
}
|
||||
taskID = &parsed
|
||||
}
|
||||
|
||||
rawToken, tokenHash, err := newDesktopClientToken()
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50087, "desktop_task_lease_token_failed", "failed to generate desktop task lease token")
|
||||
}
|
||||
|
||||
attemptID := uuid.New()
|
||||
leaseParams := repository.DesktopTaskLeaseParams{
|
||||
AttemptID: attemptID,
|
||||
LeaseTokenHash: tokenHash,
|
||||
}
|
||||
|
||||
var task *repository.DesktopTask
|
||||
switch {
|
||||
case fromParked:
|
||||
if taskID == nil {
|
||||
return nil, response.ErrBadRequest(40085, "missing_desktop_task_id", "task id is required when from_parked=true")
|
||||
}
|
||||
task, err = s.repo.LeaseParked(ctx, *taskID, client.ID, leaseParams)
|
||||
case taskID != nil:
|
||||
task, err = s.repo.LeaseQueuedByDesktopID(ctx, *taskID, client.ID, leaseParams)
|
||||
default:
|
||||
task, err = s.repo.LeaseNextQueued(ctx, client.ID, req.Kind, leaseParams)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if taskID == nil && !fromParked {
|
||||
return &LeaseDesktopTaskResponse{}, nil
|
||||
}
|
||||
return nil, s.classifyLeaseError(ctx, client, taskID, fromParked)
|
||||
}
|
||||
return nil, response.ErrInternal(50088, "desktop_task_lease_failed", "failed to lease desktop task")
|
||||
}
|
||||
|
||||
if _, attemptErr := s.repo.CreateAttempt(ctx, repository.CreateDesktopTaskAttemptParams{
|
||||
DesktopID: attemptID,
|
||||
TaskID: task.DesktopID,
|
||||
ClientID: client.ID,
|
||||
LeaseTokenHash: tokenHash,
|
||||
}); attemptErr != nil {
|
||||
s.logWarn("desktop task attempt insert failed", attemptErr, zap.String("task_id", task.DesktopID.String()))
|
||||
}
|
||||
|
||||
s.publishTaskEvent(ctx, task, "task_leased")
|
||||
|
||||
view := buildDesktopTaskView(task)
|
||||
attemptIDText := attemptID.String()
|
||||
return &LeaseDesktopTaskResponse{
|
||||
Task: &view,
|
||||
AttemptID: &attemptIDText,
|
||||
LeaseToken: &rawToken,
|
||||
LeaseExpiresAt: task.LeaseExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) Extend(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req ExtendDesktopTaskRequest) (*DesktopTaskView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
task, err := s.repo.ExtendLease(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired")
|
||||
}
|
||||
return nil, response.ErrInternal(50089, "desktop_task_extend_failed", "failed to extend desktop task lease")
|
||||
}
|
||||
|
||||
s.publishTaskEvent(ctx, task, "task_extended")
|
||||
|
||||
view := buildDesktopTaskView(task)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) Park(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req ParkDesktopTaskRequest) (*DesktopTaskView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
previousTask, _ := s.repo.GetByDesktopID(ctx, desktopID, client.WorkspaceID)
|
||||
previousAttemptID := activeAttemptID(previousTask)
|
||||
|
||||
task, err := s.repo.Park(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)), req.Reason)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired")
|
||||
}
|
||||
return nil, response.ErrInternal(50090, "desktop_task_park_failed", "failed to park desktop task")
|
||||
}
|
||||
|
||||
if previousAttemptID != nil {
|
||||
if finishErr := s.repo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{
|
||||
TaskID: task.DesktopID,
|
||||
AttemptID: *previousAttemptID,
|
||||
FinalStatus: literalStringPtr("waiting_user"),
|
||||
}); finishErr != nil {
|
||||
s.logWarn("desktop task attempt finish failed after park", finishErr, zap.String("task_id", task.DesktopID.String()))
|
||||
}
|
||||
}
|
||||
|
||||
s.publishTaskEvent(ctx, task, "task_parked")
|
||||
|
||||
view := buildDesktopTaskView(task)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req CompleteDesktopTaskRequest) (*DesktopTaskView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
previousTask, _ := s.repo.GetByDesktopID(ctx, desktopID, client.WorkspaceID)
|
||||
previousAttemptID := activeAttemptID(previousTask)
|
||||
|
||||
resultJSON, err := marshalOptionalJSON(req.Payload)
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40086, "invalid_desktop_task_payload", "payload must be serializable")
|
||||
}
|
||||
errorJSON, err := marshalOptionalJSON(req.Error)
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40087, "invalid_desktop_task_error", "error must be serializable")
|
||||
}
|
||||
|
||||
task, err := s.repo.Complete(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)), req.Status, resultJSON, errorJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired")
|
||||
}
|
||||
return nil, response.ErrInternal(50091, "desktop_task_complete_failed", "failed to complete desktop task")
|
||||
}
|
||||
|
||||
if previousAttemptID != nil {
|
||||
if finishErr := s.repo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{
|
||||
TaskID: task.DesktopID,
|
||||
AttemptID: *previousAttemptID,
|
||||
FinalStatus: &req.Status,
|
||||
Error: errorJSON,
|
||||
}); finishErr != nil {
|
||||
s.logWarn("desktop task attempt finish failed after complete", finishErr, zap.String("task_id", task.DesktopID.String()))
|
||||
}
|
||||
}
|
||||
|
||||
s.publishTaskEvent(ctx, task, "task_completed")
|
||||
|
||||
view := buildDesktopTaskView(task)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) Cancel(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req CancelDesktopTaskRequest) (*DesktopTaskView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
|
||||
previousTask, _ := s.repo.GetByDesktopID(ctx, desktopID, client.WorkspaceID)
|
||||
previousAttemptID := activeAttemptID(previousTask)
|
||||
|
||||
errorJSON, err := marshalOptionalJSON(map[string]any{
|
||||
"reason": optionalStringValue(req.Reason),
|
||||
"source": "desktop_client",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40088, "invalid_desktop_task_cancel", "cancel payload must be serializable")
|
||||
}
|
||||
|
||||
var task *repository.DesktopTask
|
||||
if req.LeaseToken != nil && strings.TrimSpace(*req.LeaseToken) != "" {
|
||||
task, err = s.repo.CancelByLease(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(*req.LeaseToken)), errorJSON)
|
||||
} else {
|
||||
task, err = s.repo.CancelByClient(ctx, desktopID, client.ID, errorJSON)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40984, "desktop_task_cancel_conflict", "desktop task cannot be canceled in its current state")
|
||||
}
|
||||
return nil, response.ErrInternal(50092, "desktop_task_cancel_failed", "failed to cancel desktop task")
|
||||
}
|
||||
|
||||
if previousAttemptID != nil && req.LeaseToken != nil && strings.TrimSpace(*req.LeaseToken) != "" {
|
||||
if finishErr := s.repo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{
|
||||
TaskID: task.DesktopID,
|
||||
AttemptID: *previousAttemptID,
|
||||
FinalStatus: literalStringPtr("aborted"),
|
||||
Error: errorJSON,
|
||||
}); finishErr != nil {
|
||||
s.logWarn("desktop task attempt finish failed after cancel", finishErr, zap.String("task_id", task.DesktopID.String()))
|
||||
}
|
||||
}
|
||||
|
||||
s.publishTaskEvent(ctx, task, "task_canceled")
|
||||
|
||||
view := buildDesktopTaskView(task)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) TenantCancel(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, req CancelDesktopTaskRequest) (*DesktopTaskView, error) {
|
||||
if actor.PrimaryWorkspaceID == 0 {
|
||||
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
||||
}
|
||||
|
||||
errorJSON, err := marshalOptionalJSON(map[string]any{
|
||||
"reason": optionalStringValue(req.Reason),
|
||||
"source": "tenant_web",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40088, "invalid_desktop_task_cancel", "cancel payload must be serializable")
|
||||
}
|
||||
|
||||
task, err := s.repo.TenantCancel(ctx, desktopID, actor.PrimaryWorkspaceID, errorJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40984, "desktop_task_cancel_conflict", "desktop task cannot be canceled in its current state")
|
||||
}
|
||||
return nil, response.ErrInternal(50093, "desktop_task_tenant_cancel_failed", "failed to cancel desktop task")
|
||||
}
|
||||
|
||||
s.publishTaskEvent(ctx, task, "task_canceled")
|
||||
|
||||
view := buildDesktopTaskView(task)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) Reconcile(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, req ReconcileDesktopTaskRequest) (*DesktopTaskView, error) {
|
||||
if actor.PrimaryWorkspaceID == 0 {
|
||||
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
||||
}
|
||||
|
||||
resultJSON, err := marshalOptionalJSON(req.Result)
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40089, "invalid_desktop_task_reconcile_result", "result must be serializable")
|
||||
}
|
||||
errorJSON, err := marshalOptionalJSON(req.Error)
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40090, "invalid_desktop_task_reconcile_error", "error must be serializable")
|
||||
}
|
||||
|
||||
task, err := s.repo.Reconcile(ctx, desktopID, actor.PrimaryWorkspaceID, req.Status, resultJSON, errorJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40985, "desktop_task_reconcile_conflict", "desktop task is not waiting for reconcile")
|
||||
}
|
||||
return nil, response.ErrInternal(50094, "desktop_task_reconcile_failed", "failed to reconcile desktop task")
|
||||
}
|
||||
|
||||
s.publishTaskEvent(ctx, task, "task_reconciled")
|
||||
|
||||
view := buildDesktopTaskView(task)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) classifyLeaseError(ctx context.Context, client *repository.DesktopClient, taskID *uuid.UUID, fromParked bool) error {
|
||||
if taskID == nil {
|
||||
return response.ErrConflict(40986, "desktop_task_unavailable", "no desktop task is currently available")
|
||||
}
|
||||
|
||||
task, err := s.repo.GetByDesktopID(ctx, *taskID, client.WorkspaceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found")
|
||||
}
|
||||
return response.ErrInternal(50095, "desktop_task_lookup_failed", "failed to inspect desktop task state")
|
||||
}
|
||||
|
||||
if task.TargetClientID != client.ID {
|
||||
return response.ErrForbidden(40381, "desktop_task_not_target_client", "desktop task is assigned to another desktop client")
|
||||
}
|
||||
|
||||
if fromParked {
|
||||
switch task.Status {
|
||||
case "aborted":
|
||||
return response.ErrConflict(40987, "desktop_task_aborted", "desktop task has already been aborted")
|
||||
case "succeeded", "failed", "unknown":
|
||||
return response.ErrConflict(40988, "desktop_task_terminal", "desktop task is already in a terminal state")
|
||||
case "in_progress":
|
||||
return response.ErrConflict(40989, "desktop_task_already_in_progress", "desktop task is already in progress")
|
||||
default:
|
||||
return response.ErrConflict(40990, "desktop_task_not_parked", "desktop task is not waiting_user")
|
||||
}
|
||||
}
|
||||
|
||||
if task.Status == "in_progress" {
|
||||
return response.ErrConflict(40989, "desktop_task_already_in_progress", "desktop task is already in progress")
|
||||
}
|
||||
|
||||
return response.ErrConflict(40991, "desktop_task_not_queued", "desktop task is not queued")
|
||||
}
|
||||
|
||||
func buildDesktopTaskView(task *repository.DesktopTask) DesktopTaskView {
|
||||
var activeAttemptID *string
|
||||
if task.ActiveAttemptID != nil {
|
||||
value := task.ActiveAttemptID.String()
|
||||
activeAttemptID = &value
|
||||
}
|
||||
|
||||
return DesktopTaskView{
|
||||
ID: task.DesktopID.String(),
|
||||
JobID: task.JobID.String(),
|
||||
TenantID: task.TenantID,
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
TargetAccountID: task.TargetAccountID.String(),
|
||||
TargetClientID: task.TargetClientID.String(),
|
||||
Platform: task.Platform,
|
||||
Kind: task.Kind,
|
||||
Payload: json.RawMessage(task.Payload),
|
||||
Status: task.Status,
|
||||
DedupKey: task.DedupKey,
|
||||
ActiveAttemptID: activeAttemptID,
|
||||
LeaseExpiresAt: task.LeaseExpiresAt,
|
||||
Attempts: task.Attempts,
|
||||
ParkedReason: task.ParkedReason,
|
||||
Result: json.RawMessage(task.Result),
|
||||
Error: json.RawMessage(task.Error),
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func marshalOptionalJSON(value any) ([]byte, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func optionalStringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
func literalStringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func activeAttemptID(task *repository.DesktopTask) *uuid.UUID {
|
||||
if task == nil || task.ActiveAttemptID == nil {
|
||||
return nil
|
||||
}
|
||||
value := *task.ActiveAttemptID
|
||||
return &value
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) publishTaskEvent(ctx context.Context, task *repository.DesktopTask, eventType string) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := publishDesktopTaskEvent(ctx, s.messaging, DesktopTaskEvent{
|
||||
Type: eventType,
|
||||
TaskID: task.DesktopID.String(),
|
||||
JobID: task.JobID.String(),
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
TargetClientID: task.TargetClientID.String(),
|
||||
Status: task.Status,
|
||||
Kind: task.Kind,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
s.logWarn("desktop task event publish failed", err, zap.String("task_id", task.DesktopID.String()), zap.String("event_type", eventType))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) logWarn(message string, err error, fields ...zap.Field) {
|
||||
if s.logger == nil || err == nil {
|
||||
return
|
||||
}
|
||||
fields = append(fields, zap.Error(err))
|
||||
s.logger.Warn(message, fields...)
|
||||
}
|
||||
@@ -314,11 +314,16 @@ func (s *MediaService) RegisterPluginInstallation(ctx context.Context, req Regis
|
||||
func ensureMonitoringPrimaryInstallation(ctx context.Context, tx pgx.Tx, tenantID, installationID int64) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO tenant_monitoring_quotas (
|
||||
tenant_id, primary_installation_id
|
||||
tenant_id, workspace_id, primary_installation_id
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
(SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1),
|
||||
$2
|
||||
)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (tenant_id) DO UPDATE
|
||||
SET primary_installation_id = COALESCE(tenant_monitoring_quotas.primary_installation_id, EXCLUDED.primary_installation_id),
|
||||
SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id),
|
||||
primary_installation_id = COALESCE(tenant_monitoring_quotas.primary_installation_id, EXCLUDED.primary_installation_id),
|
||||
updated_at = NOW()
|
||||
`, tenantID, installationID); err != nil {
|
||||
return response.ErrInternal(50068, "monitoring_quota_update_failed", "failed to assign monitoring plugin installation")
|
||||
@@ -652,7 +657,11 @@ func (s *MediaService) HandleBindCallback(ctx context.Context, req PluginBindCal
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id
|
||||
FROM platform_accounts
|
||||
WHERE tenant_id = $1 AND platform_id = $2 AND platform_uid = $3 AND deleted_at IS NULL
|
||||
WHERE tenant_id = $1
|
||||
AND workspace_id = (SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1)
|
||||
AND platform_id = $2
|
||||
AND platform_uid = $3
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`, session.TenantID, req.PlatformID, req.PlatformUID).Scan(&platformAccountID)
|
||||
|
||||
@@ -660,10 +669,29 @@ func (s *MediaService) HandleBindCallback(ctx context.Context, req PluginBindCal
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO platform_accounts (
|
||||
tenant_id, user_id, platform_id, platform_uid, nickname, avatar_url, status,
|
||||
metadata_json, last_check_at
|
||||
tenant_id, workspace_id, user_id, platform_id, platform_uid, nickname, avatar_url, status,
|
||||
metadata_json, last_check_at, desktop_id, client_id, account_fingerprint,
|
||||
display_name, health, verified_at, tags, sync_version
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
(SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1),
|
||||
$2, $3, $4, $5, $6, $7, $8, NOW(),
|
||||
gen_random_uuid(), NULL, NULL,
|
||||
$5,
|
||||
CASE
|
||||
WHEN $7 = 'active' THEN 'live'
|
||||
WHEN $7 = 'captcha' THEN 'captcha'
|
||||
WHEN $7 = 'risk' THEN 'risk'
|
||||
ELSE 'expired'
|
||||
END,
|
||||
NOW(),
|
||||
CASE
|
||||
WHEN $8 IS NOT NULL AND jsonb_typeof($8 -> 'tags') = 'array' THEN $8 -> 'tags'
|
||||
ELSE NULL
|
||||
END,
|
||||
1
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||
RETURNING id
|
||||
`, session.TenantID, session.UserID, req.PlatformID, req.PlatformUID, req.Nickname, req.AvatarURL, status, metadataJSON).Scan(&platformAccountID); err != nil {
|
||||
return nil, response.ErrInternal(50045, "platform_account_insert_failed", "failed to create platform account")
|
||||
@@ -675,10 +703,23 @@ func (s *MediaService) HandleBindCallback(ctx context.Context, req PluginBindCal
|
||||
UPDATE platform_accounts
|
||||
SET user_id = $1,
|
||||
nickname = $2,
|
||||
display_name = $2,
|
||||
avatar_url = $3,
|
||||
status = $4,
|
||||
health = CASE
|
||||
WHEN $4 = 'active' THEN 'live'
|
||||
WHEN $4 = 'captcha' THEN 'captcha'
|
||||
WHEN $4 = 'risk' THEN 'risk'
|
||||
ELSE 'expired'
|
||||
END,
|
||||
metadata_json = $5,
|
||||
last_check_at = NOW(),
|
||||
verified_at = NOW(),
|
||||
tags = CASE
|
||||
WHEN $5 IS NOT NULL AND jsonb_typeof($5 -> 'tags') = 'array' THEN $5 -> 'tags'
|
||||
ELSE tags
|
||||
END,
|
||||
sync_version = sync_version + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $6
|
||||
`, session.UserID, req.Nickname, req.AvatarURL, status, metadataJSON, platformAccountID); err != nil {
|
||||
|
||||
@@ -785,11 +785,16 @@ func (s *MonitoringCallbackService) isMonitoringInstallationRecentlyOnline(ctx c
|
||||
func (s *MonitoringCallbackService) persistHeartbeatPrimaryInstallationID(ctx context.Context, tenantID, installationID int64) error {
|
||||
if _, err := s.businessPool.Exec(ctx, `
|
||||
INSERT INTO tenant_monitoring_quotas (
|
||||
tenant_id, primary_installation_id
|
||||
tenant_id, workspace_id, primary_installation_id
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
(SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1),
|
||||
$2
|
||||
)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (tenant_id) DO UPDATE
|
||||
SET primary_installation_id = EXCLUDED.primary_installation_id,
|
||||
SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id),
|
||||
primary_installation_id = EXCLUDED.primary_installation_id,
|
||||
updated_at = NOW()
|
||||
`, tenantID, installationID); err != nil {
|
||||
return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring installation")
|
||||
|
||||
@@ -637,11 +637,16 @@ func (s *MonitoringService) findLatestOnlineInstallation(ctx context.Context, te
|
||||
func (s *MonitoringService) persistPrimaryInstallationID(ctx context.Context, tenantID, installationID int64) error {
|
||||
if _, err := s.businessPool.Exec(ctx, `
|
||||
INSERT INTO tenant_monitoring_quotas (
|
||||
tenant_id, primary_installation_id
|
||||
tenant_id, workspace_id, primary_installation_id
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
(SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1),
|
||||
$2
|
||||
)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (tenant_id) DO UPDATE
|
||||
SET primary_installation_id = EXCLUDED.primary_installation_id,
|
||||
SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id),
|
||||
primary_installation_id = EXCLUDED.primary_installation_id,
|
||||
updated_at = NOW()
|
||||
`, tenantID, installationID); err != nil {
|
||||
return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring installation")
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type PublishJobService struct {
|
||||
pool *pgxpool.Pool
|
||||
messaging *rabbitmq.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewPublishJobService(pool *pgxpool.Pool, messaging *rabbitmq.Client, logger *zap.Logger) *PublishJobService {
|
||||
return &PublishJobService{
|
||||
pool: pool,
|
||||
messaging: messaging,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type CreatePublishJobAccountRequest struct {
|
||||
AccountID string `json:"account_id" binding:"required"`
|
||||
Mode string `json:"mode" binding:"required,oneof=auto manual"`
|
||||
}
|
||||
|
||||
type CreatePublishJobRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
ContentRef map[string]any `json:"content_ref" binding:"required"`
|
||||
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
}
|
||||
|
||||
type CreatePublishJobResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
TaskIDs []string `json:"task_ids"`
|
||||
}
|
||||
|
||||
func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req CreatePublishJobRequest) (*CreatePublishJobResponse, error) {
|
||||
if actor.TenantID == 0 || actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 {
|
||||
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
||||
}
|
||||
|
||||
if s.pool == nil {
|
||||
return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable")
|
||||
}
|
||||
|
||||
contentRefJSON, err := marshalOptionalJSON(req.ContentRef)
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40091, "invalid_content_ref", "content_ref must be serializable")
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50096, "desktop_publish_job_begin_failed", "failed to start desktop publish job transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
accountRepo := repository.NewDesktopAccountRepository(tx)
|
||||
taskRepo := repository.NewDesktopTaskRepository(tx)
|
||||
|
||||
jobID := uuid.New()
|
||||
job, err := taskRepo.CreatePublishJob(ctx, repository.CreateDesktopPublishJobParams{
|
||||
DesktopID: jobID,
|
||||
TenantID: actor.TenantID,
|
||||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||||
CreatedByUserID: actor.UserID,
|
||||
Title: req.Title,
|
||||
ContentRef: contentRefJSON,
|
||||
ScheduledAt: req.ScheduledAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job")
|
||||
}
|
||||
|
||||
taskIDs := make([]string, 0, len(req.Accounts))
|
||||
createdTasks := make([]*repository.DesktopTask, 0, len(req.Accounts))
|
||||
for _, accountReq := range req.Accounts {
|
||||
accountID, parseErr := uuid.Parse(strings.TrimSpace(accountReq.AccountID))
|
||||
if parseErr != nil {
|
||||
return nil, response.ErrBadRequest(40092, "invalid_desktop_account_id", "account_id must be a uuid")
|
||||
}
|
||||
|
||||
account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID)
|
||||
if lookupErr != nil {
|
||||
if errors.Is(lookupErr, pgx.ErrNoRows) {
|
||||
return nil, response.ErrBadRequest(40093, "desktop_account_not_found", "one or more selected desktop accounts do not exist")
|
||||
}
|
||||
return nil, response.ErrInternal(50098, "desktop_account_lookup_failed", "failed to load selected desktop account")
|
||||
}
|
||||
if account.ClientID == nil {
|
||||
return nil, response.ErrConflict(40992, "desktop_account_client_missing", "one or more selected desktop accounts are not currently bound to a desktop client")
|
||||
}
|
||||
|
||||
payloadJSON, payloadErr := marshalOptionalJSON(map[string]any{
|
||||
"title": req.Title,
|
||||
"content_ref": req.ContentRef,
|
||||
"mode": accountReq.Mode,
|
||||
"account_id": account.DesktopID.String(),
|
||||
"platform": account.Platform,
|
||||
})
|
||||
if payloadErr != nil {
|
||||
return nil, response.ErrBadRequest(40094, "invalid_desktop_task_payload", "publish job payload must be serializable")
|
||||
}
|
||||
|
||||
task, createErr := taskRepo.CreateTask(ctx, repository.CreateDesktopTaskParams{
|
||||
DesktopID: uuid.New(),
|
||||
JobID: job.DesktopID,
|
||||
TenantID: actor.TenantID,
|
||||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||||
TargetAccountID: account.DesktopID,
|
||||
TargetClientID: *account.ClientID,
|
||||
Platform: account.Platform,
|
||||
Kind: "publish",
|
||||
Payload: payloadJSON,
|
||||
Status: "queued",
|
||||
})
|
||||
if createErr != nil {
|
||||
return nil, response.ErrInternal(50099, "desktop_task_create_failed", "failed to create desktop publish task")
|
||||
}
|
||||
|
||||
createdTasks = append(createdTasks, task)
|
||||
taskIDs = append(taskIDs, task.DesktopID.String())
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50100, "desktop_publish_job_commit_failed", "failed to commit desktop publish job")
|
||||
}
|
||||
|
||||
for _, task := range createdTasks {
|
||||
if publishErr := publishDesktopTaskEvent(context.Background(), s.messaging, DesktopTaskEvent{
|
||||
Type: "task_available",
|
||||
TaskID: task.DesktopID.String(),
|
||||
JobID: task.JobID.String(),
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
TargetClientID: task.TargetClientID.String(),
|
||||
Status: task.Status,
|
||||
Kind: task.Kind,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
}); publishErr != nil {
|
||||
s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String()))
|
||||
}
|
||||
}
|
||||
|
||||
return &CreatePublishJobResponse{
|
||||
JobID: job.DesktopID.String(),
|
||||
TaskIDs: taskIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) {
|
||||
if s.logger == nil || err == nil {
|
||||
return
|
||||
}
|
||||
fields = append(fields, zap.Error(err))
|
||||
s.logger.Warn(message, fields...)
|
||||
}
|
||||
Reference in New Issue
Block a user