feat(desktop): isolate platform accounts per desktop client
Scope desktop media accounts by the authenticated desktop client_id so the same user logging in from Mac and Windows no longer sees a shared account list. The list, identity lookup, upsert, patch, tombstone and async health sink paths now all require matching client_id, and the active uniqueness index moves from (workspace, platform, uid) to (workspace, client, platform, uid). Also strengthen the desktop client_id seed: when the OS machine id is unavailable, fall back to a hashed fingerprint of stable hardware MAC addresses before the random installation id, so the same hardware keeps the same client across reinstalls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('electron/main', () => ({
|
||||||
|
app: {
|
||||||
|
getPath: () => '/tmp/geo-rankly-test-user-data',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { resolveDesktopClientID } from './device-id'
|
||||||
|
|
||||||
|
describe('desktop device client id', () => {
|
||||||
|
it('is stable for the same hardware and account scope', () => {
|
||||||
|
const scope = {
|
||||||
|
tenant_id: 1,
|
||||||
|
workspace_id: 2,
|
||||||
|
user_id: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(resolveDesktopClientID(scope)).toBe(resolveDesktopClientID(scope))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps different account scopes separate on the same hardware', () => {
|
||||||
|
const base = resolveDesktopClientID({
|
||||||
|
tenant_id: 1,
|
||||||
|
workspace_id: 2,
|
||||||
|
user_id: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveDesktopClientID({
|
||||||
|
tenant_id: 1,
|
||||||
|
workspace_id: 2,
|
||||||
|
user_id: 4,
|
||||||
|
}),
|
||||||
|
).not.toBe(base)
|
||||||
|
expect(
|
||||||
|
resolveDesktopClientID({
|
||||||
|
tenant_id: 1,
|
||||||
|
workspace_id: 5,
|
||||||
|
user_id: 3,
|
||||||
|
}),
|
||||||
|
).not.toBe(base)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing account scope instead of generating a random id', () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveDesktopClientID({
|
||||||
|
tenant_id: 1,
|
||||||
|
workspace_id: 0,
|
||||||
|
user_id: 3,
|
||||||
|
}),
|
||||||
|
).toThrow('desktop_client_id_scope_invalid')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { execFileSync } from 'node:child_process'
|
import { execFileSync } from 'node:child_process'
|
||||||
import { createHash, randomUUID } from 'node:crypto'
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { hostname } from 'node:os'
|
import { hostname, networkInterfaces } from 'node:os'
|
||||||
|
import type { NetworkInterfaceInfo } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
import { app } from 'electron/main'
|
import { app } from 'electron/main'
|
||||||
@@ -44,7 +45,16 @@ export function resolveDesktopClientID(scope: DesktopClientIDScope): string {
|
|||||||
|
|
||||||
function stableMachineIdentitySeed(): string {
|
function stableMachineIdentitySeed(): string {
|
||||||
const machineID = resolveOSMachineID()
|
const machineID = resolveOSMachineID()
|
||||||
return machineID ? `machine:${machineID}` : `installation:${resolvePersistedInstallationID()}`
|
if (machineID) {
|
||||||
|
return `machine:${machineID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const networkFingerprint = resolveNetworkHardwareFingerprint()
|
||||||
|
if (networkFingerprint) {
|
||||||
|
return `network:${networkFingerprint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `installation:${resolvePersistedInstallationID()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOSMachineID(): string | null {
|
function resolveOSMachineID(): string | null {
|
||||||
@@ -113,6 +123,49 @@ function normalizeMachineID(value: string | null): string | null {
|
|||||||
return createHash('sha256').update(normalized).digest('hex')
|
return createHash('sha256').update(normalized).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveNetworkHardwareFingerprint(): string | null {
|
||||||
|
const macs = Object.values(networkInterfaces())
|
||||||
|
.flatMap((items) => items ?? [])
|
||||||
|
.filter(isStableHardwareNetworkInterface)
|
||||||
|
.map((item) => normalizeMacAddress(item.mac))
|
||||||
|
.filter((mac): mac is string => Boolean(mac))
|
||||||
|
|
||||||
|
const uniqueMacs = [...new Set(macs)].sort()
|
||||||
|
if (!uniqueMacs.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return createHash('sha256').update(uniqueMacs.join('|')).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStableHardwareNetworkInterface(item: NetworkInterfaceInfo): boolean {
|
||||||
|
const mac = normalizeMacAddress(item.mac)
|
||||||
|
if (!mac || item.internal) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = mac.split(':').map((part) => Number.parseInt(part, 16))
|
||||||
|
if (bytes.length !== 6 || bytes.some((byte) => !Number.isFinite(byte))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstByte = bytes[0] ?? 0
|
||||||
|
const isMulticast = (firstByte & 0x01) === 0x01
|
||||||
|
const isLocallyAdministered = (firstByte & 0x02) === 0x02
|
||||||
|
return !isMulticast && !isLocallyAdministered
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMacAddress(value: string | undefined): string | null {
|
||||||
|
const normalized = (value ?? '').trim().toLowerCase()
|
||||||
|
if (!normalized || normalized === '00:00:00:00:00:00') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/.test(normalized)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePersistedInstallationID(): string {
|
function resolvePersistedInstallationID(): string {
|
||||||
const existing = readPersistedInstallationID()
|
const existing = readPersistedInstallationID()
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -271,3 +271,11 @@
|
|||||||
- Desktop publish management now surfaces scheduled-publish compliance recheck blocks through publish job metadata: `publish_job_status`, `compliance_blocked_record_id`, `compliance_blocked_at`, and summarized `compliance_blocked_reason`.
|
- Desktop publish management now surfaces scheduled-publish compliance recheck blocks through publish job metadata: `publish_job_status`, `compliance_blocked_record_id`, `compliance_blocked_at`, and summarized `compliance_blocked_reason`.
|
||||||
- Verification passed on 2026-05-05: targeted compliance Go tests, full `go test ./...`, admin-web typecheck/build, ops-web typecheck/build, desktop-client typecheck/build, and `git diff --check`.
|
- Verification passed on 2026-05-05: targeted compliance Go tests, full `go test ./...`, admin-web typecheck/build, ops-web typecheck/build, desktop-client typecheck/build, and `git diff --check`.
|
||||||
- Residual risk: build output still reports large Ant Design / Milkdown chunks, but this is an existing bundle-size warning rather than a functional failure.
|
- Residual risk: build output still reports large Ant Design / Milkdown chunks, but this is an existing bundle-size warning rather than a functional failure.
|
||||||
|
|
||||||
|
## Desktop Client Account Isolation - 2026-05-06
|
||||||
|
- Root cause: `/api/desktop/accounts` was calling `ListByUser`, so it returned all active platform accounts for `workspace_id + user_id`. The same user logging in on Mac and Windows therefore saw the same bound media accounts.
|
||||||
|
- Second root cause: desktop account upsert used `ON CONFLICT (workspace_id, platform_id, platform_uid)`, so the same third-party UID on another machine could update the existing row and move its `client_id`.
|
||||||
|
- Fix: desktop account listing, identity lookup, upsert, patch, delete, and async health sink paths are now scoped by the current desktop token's `client_id`.
|
||||||
|
- Migration `20260506120000_scope_platform_accounts_to_desktop_client` replaces the active platform-account uniqueness index with `(workspace_id, client_id, platform_id, platform_uid)`.
|
||||||
|
- Desktop client IDs are still stable hardware-derived UUIDs, but the seed intentionally includes tenant/workspace/user scope so two users on the same physical computer do not share one client record/token.
|
||||||
|
- Device fingerprinting now prefers OS-level machine IDs and uses a filtered MAC-address fingerprint only when OS machine IDs are unavailable; raw hardware identifiers are hashed locally and are not sent to the server.
|
||||||
|
|||||||
+17
@@ -790,3 +790,20 @@
|
|||||||
- `cd server && go test ./...`
|
- `cd server && go test ./...`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
- `ruby -e 'require "yaml"; YAML.load_file("deploy/monitoring/article-generation-alerts.yml"); puts "ok"'`
|
- `ruby -e 'require "yaml"; YAML.load_file("deploy/monitoring/article-generation-alerts.yml"); puts "ok"'`
|
||||||
|
|
||||||
|
## 2026-05-06T13:05:00+08:00 - Desktop client account isolation
|
||||||
|
|
||||||
|
- Fixed the desktop media account cross-device leak reported from Mac/Windows login:
|
||||||
|
- `/api/desktop/accounts` now lists accounts by the authenticated desktop `client_id`, not `workspace_id + user_id`.
|
||||||
|
- Desktop account identity lookup and upsert now use `(workspace_id, client_id, platform_id, platform_uid)`, preventing one machine from overwriting another machine's row for the same third-party UID.
|
||||||
|
- Desktop-side patch/delete and async health sink updates now also require matching `client_id`.
|
||||||
|
- Added migration `20260506120000_scope_platform_accounts_to_desktop_client` to update the active uniqueness index.
|
||||||
|
- Added MAC-address fingerprint fallback for desktop client ID generation while keeping OS machine ID as the preferred stable source.
|
||||||
|
- Verification passed:
|
||||||
|
- `cd server && make sqlc-generate`
|
||||||
|
- `cd server && go test ./internal/tenant/app ./internal/tenant/repository ./internal/tenant/transport -count=1`
|
||||||
|
- `pnpm --filter @geo/desktop-client typecheck`
|
||||||
|
- `pnpm --filter @geo/desktop-client exec vitest run src/main/device-id.test.ts`
|
||||||
|
- `git diff --check`
|
||||||
|
- Caveat:
|
||||||
|
- `pnpm --filter @geo/desktop-client test -- src/main/device-id.test.ts` ran unrelated existing tests and failed in `deepseek.test.ts` due to a pre-existing missing `electron/main` mock; the new device-id test passes when run directly with vitest.
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ func validateBufferedDesktopAccountHealthReport(report bufferedDesktopAccountHea
|
|||||||
if _, err := uuid.Parse(strings.TrimSpace(report.AccountID)); err != nil {
|
if _, err := uuid.Parse(strings.TrimSpace(report.AccountID)); err != nil {
|
||||||
return fmt.Errorf("invalid account_id: %w", err)
|
return fmt.Errorf("invalid account_id: %w", err)
|
||||||
}
|
}
|
||||||
|
if _, err := uuid.Parse(strings.TrimSpace(report.ClientID)); err != nil {
|
||||||
|
return fmt.Errorf("invalid client_id: %w", err)
|
||||||
|
}
|
||||||
if report.WorkspaceID == 0 {
|
if report.WorkspaceID == 0 {
|
||||||
return fmt.Errorf("workspace_id is required")
|
return fmt.Errorf("workspace_id is required")
|
||||||
}
|
}
|
||||||
@@ -188,9 +191,10 @@ SET health = $1,
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE desktop_id = $5::uuid
|
WHERE desktop_id = $5::uuid
|
||||||
AND workspace_id = $6
|
AND workspace_id = $6
|
||||||
|
AND client_id = $7::uuid
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND (last_check_at IS NULL OR last_check_at <= $4::timestamptz)
|
AND (last_check_at IS NULL OR last_check_at <= $4::timestamptz)
|
||||||
`, report.Health, desktopAccountLegacyStatusFromHealth(report.Health), verifiedAt, checkedAt, report.AccountID, report.WorkspaceID)
|
`, report.Health, desktopAccountLegacyStatusFromHealth(report.Health), verifiedAt, checkedAt, report.AccountID, report.WorkspaceID, report.ClientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("sink desktop account health snapshot: %w", err)
|
return fmt.Errorf("sink desktop account health snapshot: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,12 +133,12 @@ type bufferedDesktopAccountHealthReport struct {
|
|||||||
ReceivedAt time.Time `json:"received_at"`
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DesktopAccountService) ListByUser(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) {
|
func (s *DesktopAccountService) ListByClient(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.repo.ListByUser(ctx, client.WorkspaceID, client.UserID)
|
rows, err := s.repo.ListByClient(ctx, client.WorkspaceID, client.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50081, "desktop_accounts_query_failed", "failed to list desktop accounts")
|
return nil, response.ErrInternal(50081, "desktop_accounts_query_failed", "failed to list desktop accounts")
|
||||||
}
|
}
|
||||||
@@ -177,7 +177,7 @@ func (s *DesktopAccountService) Upsert(ctx context.Context, client *repository.D
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.IfSyncVersion != nil {
|
if req.IfSyncVersion != nil {
|
||||||
existing, err := s.repo.GetByIdentity(ctx, client.WorkspaceID, req.Platform, req.PlatformUID)
|
existing, err := s.repo.GetByIdentity(ctx, client.WorkspaceID, client.ID, req.Platform, req.PlatformUID)
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, response.ErrInternal(50082, "desktop_account_lookup_failed", "failed to inspect desktop account before upsert")
|
return nil, response.ErrInternal(50082, "desktop_account_lookup_failed", "failed to inspect desktop account before upsert")
|
||||||
}
|
}
|
||||||
@@ -346,6 +346,7 @@ func (s *DesktopAccountService) Patch(ctx context.Context, client *repository.De
|
|||||||
VerifiedAt: req.VerifiedAt,
|
VerifiedAt: req.VerifiedAt,
|
||||||
Tags: req.Tags,
|
Tags: req.Tags,
|
||||||
IfSyncVersion: req.IfSyncVersion,
|
IfSyncVersion: req.IfSyncVersion,
|
||||||
|
ClientID: client.ID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -363,7 +364,7 @@ func (s *DesktopAccountService) Tombstone(ctx context.Context, client *repositor
|
|||||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
account, err := s.repo.Tombstone(ctx, desktopID, client.WorkspaceID, ifSyncVersion)
|
account, err := s.repo.Tombstone(ctx, desktopID, client.WorkspaceID, client.ID, ifSyncVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict")
|
return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict")
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type UpsertDesktopAccountParams struct {
|
|||||||
type PatchDesktopAccountParams struct {
|
type PatchDesktopAccountParams struct {
|
||||||
DesktopID uuid.UUID
|
DesktopID uuid.UUID
|
||||||
WorkspaceID int64
|
WorkspaceID int64
|
||||||
|
ClientID uuid.UUID
|
||||||
DisplayName *string
|
DisplayName *string
|
||||||
Health *string
|
Health *string
|
||||||
VerifiedAt *time.Time
|
VerifiedAt *time.Time
|
||||||
@@ -60,12 +61,13 @@ type PatchDesktopAccountParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DesktopAccountRepository interface {
|
type DesktopAccountRepository interface {
|
||||||
|
ListByClient(ctx context.Context, workspaceID int64, clientID uuid.UUID) ([]DesktopAccount, error)
|
||||||
ListByUser(ctx context.Context, workspaceID, userID int64) ([]DesktopAccount, error)
|
ListByUser(ctx context.Context, workspaceID, userID int64) ([]DesktopAccount, error)
|
||||||
GetByDesktopID(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
|
GetByDesktopID(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
|
||||||
GetByIdentity(ctx context.Context, workspaceID int64, platform, platformUID string) (*DesktopAccount, error)
|
GetByIdentity(ctx context.Context, workspaceID int64, clientID uuid.UUID, platform, platformUID string) (*DesktopAccount, error)
|
||||||
Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error)
|
Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error)
|
||||||
Patch(ctx context.Context, params PatchDesktopAccountParams) (*DesktopAccount, error)
|
Patch(ctx context.Context, params PatchDesktopAccountParams) (*DesktopAccount, error)
|
||||||
Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID, ifSyncVersion int64) (*DesktopAccount, error)
|
Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID int64, clientID uuid.UUID, ifSyncVersion int64) (*DesktopAccount, error)
|
||||||
MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
|
MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
|
||||||
ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
|
ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,26 @@ func NewDesktopAccountRepository(db generated.DBTX) DesktopAccountRepository {
|
|||||||
return &desktopAccountRepository{q: newQuerier(db)}
|
return &desktopAccountRepository{q: newQuerier(db)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *desktopAccountRepository) ListByClient(ctx context.Context, workspaceID int64, clientID uuid.UUID) ([]DesktopAccount, error) {
|
||||||
|
rows, err := r.q.ListDesktopAccountsByClient(ctx, generated.ListDesktopAccountsByClientParams{
|
||||||
|
WorkspaceID: workspaceID,
|
||||||
|
ClientID: pgUUID(clientID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]DesktopAccount, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
item, err := desktopAccountFromClientListRow(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, *item)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *desktopAccountRepository) ListByUser(ctx context.Context, workspaceID, userID int64) ([]DesktopAccount, error) {
|
func (r *desktopAccountRepository) ListByUser(ctx context.Context, workspaceID, userID int64) ([]DesktopAccount, error) {
|
||||||
rows, err := r.q.ListDesktopAccountsByUser(ctx, generated.ListDesktopAccountsByUserParams{
|
rows, err := r.q.ListDesktopAccountsByUser(ctx, generated.ListDesktopAccountsByUserParams{
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
@@ -109,9 +131,10 @@ func (r *desktopAccountRepository) GetByDesktopID(ctx context.Context, desktopID
|
|||||||
return desktopAccountFromGetRow(row)
|
return desktopAccountFromGetRow(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *desktopAccountRepository) GetByIdentity(ctx context.Context, workspaceID int64, platform, platformUID string) (*DesktopAccount, error) {
|
func (r *desktopAccountRepository) GetByIdentity(ctx context.Context, workspaceID int64, clientID uuid.UUID, platform, platformUID string) (*DesktopAccount, error) {
|
||||||
row, err := r.q.GetDesktopAccountByIdentity(ctx, generated.GetDesktopAccountByIdentityParams{
|
row, err := r.q.GetDesktopAccountByIdentity(ctx, generated.GetDesktopAccountByIdentityParams{
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
|
ClientID: pgUUID(clientID),
|
||||||
PlatformID: platform,
|
PlatformID: platform,
|
||||||
PlatformUid: platformUID,
|
PlatformUid: platformUID,
|
||||||
})
|
})
|
||||||
@@ -177,6 +200,7 @@ func (r *desktopAccountRepository) Patch(ctx context.Context, params PatchDeskto
|
|||||||
Tags: tagsJSON,
|
Tags: tagsJSON,
|
||||||
DesktopID: pgUUID(params.DesktopID),
|
DesktopID: pgUUID(params.DesktopID),
|
||||||
WorkspaceID: params.WorkspaceID,
|
WorkspaceID: params.WorkspaceID,
|
||||||
|
ClientID: pgUUID(params.ClientID),
|
||||||
IfSyncVersion: params.IfSyncVersion,
|
IfSyncVersion: params.IfSyncVersion,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -185,10 +209,11 @@ func (r *desktopAccountRepository) Patch(ctx context.Context, params PatchDeskto
|
|||||||
return desktopAccountFromPatchRow(row)
|
return desktopAccountFromPatchRow(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID, ifSyncVersion int64) (*DesktopAccount, error) {
|
func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID int64, clientID uuid.UUID, ifSyncVersion int64) (*DesktopAccount, error) {
|
||||||
row, err := r.q.TombstoneDesktopAccount(ctx, generated.TombstoneDesktopAccountParams{
|
row, err := r.q.TombstoneDesktopAccount(ctx, generated.TombstoneDesktopAccountParams{
|
||||||
DesktopID: pgUUID(desktopID),
|
DesktopID: pgUUID(desktopID),
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
|
ClientID: pgUUID(clientID),
|
||||||
IfSyncVersion: ifSyncVersion,
|
IfSyncVersion: ifSyncVersion,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -219,6 +244,29 @@ func (r *desktopAccountRepository) ClearDeleteRequested(ctx context.Context, des
|
|||||||
return desktopAccountFromClearDeleteRow(row)
|
return desktopAccountFromClearDeleteRow(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func desktopAccountFromClientListRow(row generated.ListDesktopAccountsByClientRow) (*DesktopAccount, error) {
|
||||||
|
return buildDesktopAccount(
|
||||||
|
row.DesktopID,
|
||||||
|
row.TenantID,
|
||||||
|
row.WorkspaceID,
|
||||||
|
row.UserID,
|
||||||
|
row.ClientID,
|
||||||
|
row.PlatformID,
|
||||||
|
row.PlatformUid,
|
||||||
|
row.AccountFingerprint,
|
||||||
|
row.DisplayName,
|
||||||
|
row.AvatarUrl,
|
||||||
|
row.Health,
|
||||||
|
row.VerifiedAt,
|
||||||
|
row.Tags,
|
||||||
|
row.SyncVersion,
|
||||||
|
row.DeletedAt,
|
||||||
|
row.DeleteRequestedAt,
|
||||||
|
row.CreatedAt,
|
||||||
|
row.UpdatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func desktopAccountFromListRow(row generated.ListDesktopAccountsByUserRow) (*DesktopAccount, error) {
|
func desktopAccountFromListRow(row generated.ListDesktopAccountsByUserRow) (*DesktopAccount, error) {
|
||||||
return buildDesktopAccount(
|
return buildDesktopAccount(
|
||||||
row.DesktopID,
|
row.DesktopID,
|
||||||
|
|||||||
@@ -192,16 +192,18 @@ SELECT
|
|||||||
updated_at
|
updated_at
|
||||||
FROM platform_accounts
|
FROM platform_accounts
|
||||||
WHERE workspace_id = $1
|
WHERE workspace_id = $1
|
||||||
AND platform_id = $2
|
AND client_id = $2
|
||||||
AND platform_uid = $3
|
AND platform_id = $3
|
||||||
|
AND platform_uid = $4
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetDesktopAccountByIdentityParams struct {
|
type GetDesktopAccountByIdentityParams struct {
|
||||||
WorkspaceID int64 `json:"workspace_id"`
|
WorkspaceID int64 `json:"workspace_id"`
|
||||||
PlatformID string `json:"platform_id"`
|
ClientID pgtype.UUID `json:"client_id"`
|
||||||
PlatformUid string `json:"platform_uid"`
|
PlatformID string `json:"platform_id"`
|
||||||
|
PlatformUid string `json:"platform_uid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetDesktopAccountByIdentityRow struct {
|
type GetDesktopAccountByIdentityRow struct {
|
||||||
@@ -226,7 +228,12 @@ type GetDesktopAccountByIdentityRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetDesktopAccountByIdentity(ctx context.Context, arg GetDesktopAccountByIdentityParams) (GetDesktopAccountByIdentityRow, error) {
|
func (q *Queries) GetDesktopAccountByIdentity(ctx context.Context, arg GetDesktopAccountByIdentityParams) (GetDesktopAccountByIdentityRow, error) {
|
||||||
row := q.db.QueryRow(ctx, getDesktopAccountByIdentity, arg.WorkspaceID, arg.PlatformID, arg.PlatformUid)
|
row := q.db.QueryRow(ctx, getDesktopAccountByIdentity,
|
||||||
|
arg.WorkspaceID,
|
||||||
|
arg.ClientID,
|
||||||
|
arg.PlatformID,
|
||||||
|
arg.PlatformUid,
|
||||||
|
)
|
||||||
var i GetDesktopAccountByIdentityRow
|
var i GetDesktopAccountByIdentityRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.DesktopID,
|
&i.DesktopID,
|
||||||
@@ -251,6 +258,98 @@ func (q *Queries) GetDesktopAccountByIdentity(ctx context.Context, arg GetDeskto
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listDesktopAccountsByClient = `-- name: ListDesktopAccountsByClient :many
|
||||||
|
SELECT
|
||||||
|
desktop_id,
|
||||||
|
tenant_id,
|
||||||
|
workspace_id,
|
||||||
|
user_id,
|
||||||
|
client_id,
|
||||||
|
platform_id,
|
||||||
|
platform_uid,
|
||||||
|
account_fingerprint,
|
||||||
|
display_name,
|
||||||
|
avatar_url,
|
||||||
|
health,
|
||||||
|
verified_at,
|
||||||
|
tags,
|
||||||
|
sync_version,
|
||||||
|
deleted_at,
|
||||||
|
delete_requested_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM platform_accounts
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
AND client_id = $2
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY platform_id, display_name, created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListDesktopAccountsByClientParams struct {
|
||||||
|
WorkspaceID int64 `json:"workspace_id"`
|
||||||
|
ClientID pgtype.UUID `json:"client_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListDesktopAccountsByClientRow struct {
|
||||||
|
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
WorkspaceID int64 `json:"workspace_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
ClientID pgtype.UUID `json:"client_id"`
|
||||||
|
PlatformID string `json:"platform_id"`
|
||||||
|
PlatformUid string `json:"platform_uid"`
|
||||||
|
AccountFingerprint pgtype.Text `json:"account_fingerprint"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||||
|
Health string `json:"health"`
|
||||||
|
VerifiedAt pgtype.Timestamptz `json:"verified_at"`
|
||||||
|
Tags []byte `json:"tags"`
|
||||||
|
SyncVersion int64 `json:"sync_version"`
|
||||||
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listDesktopAccountsByClient, arg.WorkspaceID, arg.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListDesktopAccountsByClientRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListDesktopAccountsByClientRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.DesktopID,
|
||||||
|
&i.TenantID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.ClientID,
|
||||||
|
&i.PlatformID,
|
||||||
|
&i.PlatformUid,
|
||||||
|
&i.AccountFingerprint,
|
||||||
|
&i.DisplayName,
|
||||||
|
&i.AvatarUrl,
|
||||||
|
&i.Health,
|
||||||
|
&i.VerifiedAt,
|
||||||
|
&i.Tags,
|
||||||
|
&i.SyncVersion,
|
||||||
|
&i.DeletedAt,
|
||||||
|
&i.DeleteRequestedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listDesktopAccountsByUser = `-- name: ListDesktopAccountsByUser :many
|
const listDesktopAccountsByUser = `-- name: ListDesktopAccountsByUser :many
|
||||||
SELECT
|
SELECT
|
||||||
desktop_id,
|
desktop_id,
|
||||||
@@ -437,7 +536,8 @@ SET display_name = COALESCE($1::text, display_name),
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE desktop_id = $6
|
WHERE desktop_id = $6
|
||||||
AND workspace_id = $7
|
AND workspace_id = $7
|
||||||
AND sync_version = $8
|
AND client_id = $8
|
||||||
|
AND sync_version = $9
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING
|
RETURNING
|
||||||
desktop_id,
|
desktop_id,
|
||||||
@@ -468,6 +568,7 @@ type PatchDesktopAccountParams struct {
|
|||||||
Tags []byte `json:"tags"`
|
Tags []byte `json:"tags"`
|
||||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||||
WorkspaceID int64 `json:"workspace_id"`
|
WorkspaceID int64 `json:"workspace_id"`
|
||||||
|
ClientID pgtype.UUID `json:"client_id"`
|
||||||
IfSyncVersion int64 `json:"if_sync_version"`
|
IfSyncVersion int64 `json:"if_sync_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +602,7 @@ func (q *Queries) PatchDesktopAccount(ctx context.Context, arg PatchDesktopAccou
|
|||||||
arg.Tags,
|
arg.Tags,
|
||||||
arg.DesktopID,
|
arg.DesktopID,
|
||||||
arg.WorkspaceID,
|
arg.WorkspaceID,
|
||||||
|
arg.ClientID,
|
||||||
arg.IfSyncVersion,
|
arg.IfSyncVersion,
|
||||||
)
|
)
|
||||||
var i PatchDesktopAccountRow
|
var i PatchDesktopAccountRow
|
||||||
@@ -534,7 +636,8 @@ SET deleted_at = now(),
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE desktop_id = $1
|
WHERE desktop_id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND sync_version = $3
|
AND client_id = $3
|
||||||
|
AND sync_version = $4
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING
|
RETURNING
|
||||||
desktop_id,
|
desktop_id,
|
||||||
@@ -560,6 +663,7 @@ RETURNING
|
|||||||
type TombstoneDesktopAccountParams struct {
|
type TombstoneDesktopAccountParams struct {
|
||||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||||
WorkspaceID int64 `json:"workspace_id"`
|
WorkspaceID int64 `json:"workspace_id"`
|
||||||
|
ClientID pgtype.UUID `json:"client_id"`
|
||||||
IfSyncVersion int64 `json:"if_sync_version"`
|
IfSyncVersion int64 `json:"if_sync_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +689,12 @@ type TombstoneDesktopAccountRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesktopAccountParams) (TombstoneDesktopAccountRow, error) {
|
func (q *Queries) TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesktopAccountParams) (TombstoneDesktopAccountRow, error) {
|
||||||
row := q.db.QueryRow(ctx, tombstoneDesktopAccount, arg.DesktopID, arg.WorkspaceID, arg.IfSyncVersion)
|
row := q.db.QueryRow(ctx, tombstoneDesktopAccount,
|
||||||
|
arg.DesktopID,
|
||||||
|
arg.WorkspaceID,
|
||||||
|
arg.ClientID,
|
||||||
|
arg.IfSyncVersion,
|
||||||
|
)
|
||||||
var i TombstoneDesktopAccountRow
|
var i TombstoneDesktopAccountRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.DesktopID,
|
&i.DesktopID,
|
||||||
@@ -651,7 +760,7 @@ VALUES (
|
|||||||
$15,
|
$15,
|
||||||
1
|
1
|
||||||
)
|
)
|
||||||
ON CONFLICT (workspace_id, platform_id, platform_uid) WHERE deleted_at IS NULL
|
ON CONFLICT (workspace_id, client_id, platform_id, platform_uid) WHERE deleted_at IS NULL
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
client_id = EXCLUDED.client_id,
|
client_id = EXCLUDED.client_id,
|
||||||
user_id = EXCLUDED.user_id,
|
user_id = EXCLUDED.user_id,
|
||||||
@@ -669,6 +778,7 @@ DO UPDATE SET
|
|||||||
delete_requested_at = NULL,
|
delete_requested_at = NULL,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id
|
WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id
|
||||||
|
AND platform_accounts.client_id = EXCLUDED.client_id
|
||||||
AND (
|
AND (
|
||||||
$16::bigint IS NULL
|
$16::bigint IS NULL
|
||||||
OR platform_accounts.sync_version = $16::bigint
|
OR platform_accounts.sync_version = $16::bigint
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ type Querier interface {
|
|||||||
ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error)
|
ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error)
|
||||||
ListBrands(ctx context.Context, tenantID int64) ([]ListBrandsRow, error)
|
ListBrands(ctx context.Context, tenantID int64) ([]ListBrandsRow, error)
|
||||||
ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error)
|
ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error)
|
||||||
|
ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error)
|
||||||
ListDesktopAccountsByUser(ctx context.Context, arg ListDesktopAccountsByUserParams) ([]ListDesktopAccountsByUserRow, error)
|
ListDesktopAccountsByUser(ctx context.Context, arg ListDesktopAccountsByUserParams) ([]ListDesktopAccountsByUserRow, error)
|
||||||
ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error)
|
ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error)
|
||||||
ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error)
|
ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error)
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
-- name: ListDesktopAccountsByClient :many
|
||||||
|
SELECT
|
||||||
|
desktop_id,
|
||||||
|
tenant_id,
|
||||||
|
workspace_id,
|
||||||
|
user_id,
|
||||||
|
client_id,
|
||||||
|
platform_id,
|
||||||
|
platform_uid,
|
||||||
|
account_fingerprint,
|
||||||
|
display_name,
|
||||||
|
avatar_url,
|
||||||
|
health,
|
||||||
|
verified_at,
|
||||||
|
tags,
|
||||||
|
sync_version,
|
||||||
|
deleted_at,
|
||||||
|
delete_requested_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM platform_accounts
|
||||||
|
WHERE workspace_id = sqlc.arg(workspace_id)
|
||||||
|
AND client_id = sqlc.arg(client_id)
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY platform_id, display_name, created_at DESC;
|
||||||
|
|
||||||
-- name: ListDesktopAccountsByUser :many
|
-- name: ListDesktopAccountsByUser :many
|
||||||
SELECT
|
SELECT
|
||||||
desktop_id,
|
desktop_id,
|
||||||
@@ -71,6 +97,7 @@ SELECT
|
|||||||
updated_at
|
updated_at
|
||||||
FROM platform_accounts
|
FROM platform_accounts
|
||||||
WHERE workspace_id = sqlc.arg(workspace_id)
|
WHERE workspace_id = sqlc.arg(workspace_id)
|
||||||
|
AND client_id = sqlc.arg(client_id)
|
||||||
AND platform_id = sqlc.arg(platform_id)
|
AND platform_id = sqlc.arg(platform_id)
|
||||||
AND platform_uid = sqlc.arg(platform_uid)
|
AND platform_uid = sqlc.arg(platform_uid)
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
@@ -117,7 +144,7 @@ VALUES (
|
|||||||
sqlc.narg(tags),
|
sqlc.narg(tags),
|
||||||
1
|
1
|
||||||
)
|
)
|
||||||
ON CONFLICT (workspace_id, platform_id, platform_uid) WHERE deleted_at IS NULL
|
ON CONFLICT (workspace_id, client_id, platform_id, platform_uid) WHERE deleted_at IS NULL
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
client_id = EXCLUDED.client_id,
|
client_id = EXCLUDED.client_id,
|
||||||
user_id = EXCLUDED.user_id,
|
user_id = EXCLUDED.user_id,
|
||||||
@@ -135,6 +162,7 @@ DO UPDATE SET
|
|||||||
delete_requested_at = NULL,
|
delete_requested_at = NULL,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id
|
WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id
|
||||||
|
AND platform_accounts.client_id = EXCLUDED.client_id
|
||||||
AND (
|
AND (
|
||||||
sqlc.narg(if_sync_version)::bigint IS NULL
|
sqlc.narg(if_sync_version)::bigint IS NULL
|
||||||
OR platform_accounts.sync_version = sqlc.narg(if_sync_version)::bigint
|
OR platform_accounts.sync_version = sqlc.narg(if_sync_version)::bigint
|
||||||
@@ -172,6 +200,7 @@ SET display_name = COALESCE(sqlc.narg(display_name)::text, display_name),
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE desktop_id = sqlc.arg(desktop_id)
|
WHERE desktop_id = sqlc.arg(desktop_id)
|
||||||
AND workspace_id = sqlc.arg(workspace_id)
|
AND workspace_id = sqlc.arg(workspace_id)
|
||||||
|
AND client_id = sqlc.arg(client_id)
|
||||||
AND sync_version = sqlc.arg(if_sync_version)
|
AND sync_version = sqlc.arg(if_sync_version)
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING
|
RETURNING
|
||||||
@@ -201,6 +230,7 @@ SET deleted_at = now(),
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE desktop_id = sqlc.arg(desktop_id)
|
WHERE desktop_id = sqlc.arg(desktop_id)
|
||||||
AND workspace_id = sqlc.arg(workspace_id)
|
AND workspace_id = sqlc.arg(workspace_id)
|
||||||
|
AND client_id = sqlc.arg(client_id)
|
||||||
AND sync_version = sqlc.arg(if_sync_version)
|
AND sync_version = sqlc.arg(if_sync_version)
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING
|
RETURNING
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (h *DesktopAccountHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := h.svc.ListByUser(c.Request.Context(), MustDesktopClient(c.Request.Context()))
|
data, err := h.svc.ListByClient(c.Request.Context(), MustDesktopClient(c.Request.Context()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DROP INDEX IF EXISTS uniq_platform_accounts_workspace_identity_active;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_accounts_workspace_identity_active
|
||||||
|
ON platform_accounts (workspace_id, platform_id, platform_uid)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DROP INDEX IF EXISTS uniq_platform_accounts_workspace_identity_active;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_accounts_workspace_identity_active
|
||||||
|
ON platform_accounts (workspace_id, client_id, platform_id, platform_uid)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
+19
-1
@@ -4,7 +4,7 @@
|
|||||||
Continue the desktop AI monitoring implementation by first adding a client-side local scheduler with durable queueing and adaptive execution policy, then adding a reusable hidden Playwright CDP execution layer that attaches to Electron Chromium and reuses existing account partitions.
|
Continue the desktop AI monitoring implementation by first adding a client-side local scheduler with durable queueing and adaptive execution policy, then adding a reusable hidden Playwright CDP execution layer that attaches to Electron Chromium and reuses existing account partitions.
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
Phase 45
|
Phase 47
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
### Phase 1: Progress Verification
|
### Phase 1: Progress Verification
|
||||||
@@ -315,6 +315,20 @@ Phase 45
|
|||||||
- [x] Commit only the reliability hardening files after verification
|
- [x] Commit only the reliability hardening files after verification
|
||||||
- **Status:** complete
|
- **Status:** complete
|
||||||
|
|
||||||
|
### Phase 46: Desktop Client Account Isolation
|
||||||
|
- [x] Confirm current desktop media accounts were listed by `workspace_id + user_id`
|
||||||
|
- [x] Change desktop account listing/upsert/update/delete ownership to `workspace_id + client_id`
|
||||||
|
- [x] Add a migration that changes active platform-account uniqueness to include `client_id`
|
||||||
|
- [x] Strengthen hardware-derived desktop client ID fallback with stable network hardware fingerprinting
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
|
### Phase 47: Desktop Client Account Isolation Verification
|
||||||
|
- [x] Regenerate sqlc code after query contract changes
|
||||||
|
- [x] Run targeted backend tests for tenant app/repository/transport paths
|
||||||
|
- [x] Run desktop client typecheck and targeted device-id tests
|
||||||
|
- [x] Record residual test caveats
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
## Key Questions
|
## Key Questions
|
||||||
1. How should the desktop client defer and locally optimize monitor-task execution without violating one-task-per-platform serialism?
|
1. How should the desktop client defer and locally optimize monitor-task execution without violating one-task-per-platform serialism?
|
||||||
2. What is the smallest durable local cache that allows same-day resume while safely dropping stale next-day monitor tasks?
|
2. What is the smallest durable local cache that allows same-day resume while safely dropping stale next-day monitor tasks?
|
||||||
@@ -365,6 +379,9 @@ Phase 45
|
|||||||
| Materialize daily monitoring tasks with batched array INSERT | The per-brand cap is currently small, but one `INSERT ... SELECT FROM unnest(...) ON CONFLICT DO NOTHING` keeps DB round trips fixed if caps or retry batches grow |
|
| Materialize daily monitoring tasks with batched array INSERT | The per-brand cap is currently small, but one `INSERT ... SELECT FROM unnest(...) ON CONFLICT DO NOTHING` keeps DB round trips fixed if caps or retry batches grow |
|
||||||
| Split daily monitoring plan loading into selection and aggregation steps | Primary-client election and platform authorization aggregation have different explainability and testability concerns; two smaller queries are easier to inspect and later move to sqlc |
|
| Split daily monitoring plan loading into selection and aggregation steps | Primary-client election and platform authorization aggregation have different explainability and testability concerns; two smaller queries are easier to inspect and later move to sqlc |
|
||||||
| Treat platform-account health as execution state, not authorization state | `health != live` should not suppress daily task planning, dashboard authorization, or detail history; only desktop-client online state gates immediate collection |
|
| Treat platform-account health as execution state, not authorization state | `health != live` should not suppress daily task planning, dashboard authorization, or detail history; only desktop-client online state gates immediate collection |
|
||||||
|
| Scope desktop account data to `client_id`, not `user_id` | The same user can log in on Mac and Windows; `workspace_id + user_id` describes the person, not the physical desktop client |
|
||||||
|
| Include account scope in hardware-derived desktop client IDs | Pure hardware IDs would collide when different users use the same computer, so the seed remains hardware fingerprint plus tenant/workspace/user |
|
||||||
|
| Prefer OS machine identity before network MAC fallback | macOS `IOPlatformUUID`, Windows `MachineGuid`, and Linux `machine-id` are more stable than interface lists; MAC hashes are a hardware fallback when OS IDs are unavailable |
|
||||||
| Start k3s with a self-contained stack that mirrors the existing compose deployment | The repo already has working Docker images and compose topology; k3s should first preserve service names and runtime assumptions before introducing managed cloud replacements |
|
| Start k3s with a self-contained stack that mirrors the existing compose deployment | The repo already has working Docker images and compose topology; k3s should first preserve service names and runtime assumptions before introducing managed cloud replacements |
|
||||||
| Publish deployable images only from frontend/backend CI | Deployment config has no runtime image ownership; `deploy-config-ci` stays a validation workflow while frontend/backend CI publish Gitea Registry images tagged by commit8 |
|
| Publish deployable images only from frontend/backend CI | Deployment config has no runtime image ownership; `deploy-config-ci` stays a validation workflow while frontend/backend CI publish Gitea Registry images tagged by commit8 |
|
||||||
| Use commit hash first 8 chars as the canonical deploy image tag | CD/offline packaging can compare the pushed commit to Registry tags and fail fast when CI has not produced the matching image |
|
| Use commit hash first 8 chars as the canonical deploy image tag | CD/offline packaging can compare the pushed commit to Registry tags and fail fast when CI has not produced the matching image |
|
||||||
@@ -387,6 +404,7 @@ Phase 45
|
|||||||
| Production compose PgBouncer smoke test could not start bundled Postgres containers because existing `server/docker-compose.yaml` containers already own `geo-postgres` / `geo-monitoring-postgres` names | 1 | Did not stop user/local DB containers; verified PgBouncer with a temporary test container attached to the existing `server_default` network and removed it after `SELECT 1` passed |
|
| Production compose PgBouncer smoke test could not start bundled Postgres containers because existing `server/docker-compose.yaml` containers already own `geo-postgres` / `geo-monitoring-postgres` names | 1 | Did not stop user/local DB containers; verified PgBouncer with a temporary test container attached to the existing `server_default` network and removed it after `SELECT 1` passed |
|
||||||
| Article generation state-check worker initially failed to compile because the local DB interface returned a custom rows type instead of `pgx.Rows` | 1 | Changed the interface to match `pgxpool.Pool.Query` directly and reran targeted/full Go tests |
|
| Article generation state-check worker initially failed to compile because the local DB interface returned a custom rows type instead of `pgx.Rows` | 1 | Changed the interface to match `pgxpool.Pool.Query` directly and reran targeted/full Go tests |
|
||||||
| Prometheus last-event test depended on label output order | 1 | Changed the assertion to validate required labels independent of exporter ordering |
|
| Prometheus last-event test depended on label output order | 1 | Changed the assertion to validate required labels independent of exporter ordering |
|
||||||
|
| `pnpm --filter @geo/desktop-client test -- src/main/device-id.test.ts` ran unrelated existing tests and failed in `deepseek.test.ts` because `electron/main` was not mocked there | 1 | Verified the new test directly with `pnpm --filter @geo/desktop-client exec vitest run src/main/device-id.test.ts`; leave the existing full-test mock gap separate |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Re-check task_plan.md before major implementation decisions.
|
- Re-check task_plan.md before major implementation decisions.
|
||||||
|
|||||||
Reference in New Issue
Block a user