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,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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user