@cat-factory/app 0.153.3 → 0.153.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ import type { Ref } from 'vue'
2
+ import type { LocalModeConfig } from '@cat-factory/contracts'
3
+ import type { AuthUser } from '~/types/domain'
4
+
5
+ /**
6
+ * Shared reactive state + injected dependencies the auth-store redirect factory closes over.
7
+ * Created once in the `auth` store setup and threaded into {@link createAuthRedirectActions} so
8
+ * the split operations stay behaviourally identical to the original single-closure store — a
9
+ * size-only extraction mirroring `stores/board/` and `stores/pipelines/`, not a new seam.
10
+ */
11
+ export interface AuthRedirectContext {
12
+ api: ReturnType<typeof useApi>
13
+ token: Ref<string | null>
14
+ localMode: Ref<LocalModeConfig | null>
15
+ mothershipError: Ref<string | null>
16
+ /** Apply a freshly-minted token + user (the session factory's setter). */
17
+ applySession: (result: { token: string; user: AuthUser }) => void
18
+ }
19
+
20
+ /**
21
+ * The URL-fragment / redirect handling the boot handshake runs before it resolves a session:
22
+ * capturing a token handed back by our OWN backend's OAuth round-trip, and — in mothership
23
+ * mode — exchanging a returning MOTHERSHIP session for a local one.
24
+ */
25
+ export function createAuthRedirectActions(ctx: AuthRedirectContext) {
26
+ const { api, token, localMode, mothershipError, applySession } = ctx
27
+
28
+ /** Pull a token handed back in the post-login URL fragment (#token=…). */
29
+ function consumeRedirectToken() {
30
+ if (typeof window === 'undefined') return
31
+ const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
32
+ if (!match) return
33
+ token.value = decodeURIComponent(match[1]!)
34
+ // Strip the token from the URL so it isn't left in history or shared.
35
+ history.replaceState(null, '', window.location.pathname + window.location.search)
36
+ }
37
+
38
+ /**
39
+ * Mothership mode: when the mothership OAuth redirect returns here (flagged
40
+ * `?mothership_connect=1`), the URL fragment carries a MOTHERSHIP session — not a local one.
41
+ * Hand it to our OWN node, which exchanges it for a cached machine token and returns a LOCAL
42
+ * session for the same user. Returns true when it handled the redirect (so the caller skips
43
+ * the normal `consumeRedirectToken`, which would wrongly store the mothership session locally).
44
+ */
45
+ async function maybeConnectMothership(): Promise<boolean> {
46
+ if (typeof window === 'undefined') return false
47
+ const params = new URLSearchParams(window.location.search)
48
+ if (params.get('mothership_connect') !== '1') return false
49
+ const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
50
+ const session = match ? decodeURIComponent(match[1]!) : null
51
+ // Clean the flag + fragment from the URL regardless of outcome, so it isn't left in history.
52
+ params.delete('mothership_connect')
53
+ const qs = params.toString()
54
+ history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
55
+ if (!session) return true
56
+ try {
57
+ const result = await api.connectMothership(session)
58
+ applySession({ token: result.session, user: result.user })
59
+ mothershipError.value = null
60
+ } catch (err) {
61
+ // Surface the failure so the login screen shows it, rather than silently dropping the
62
+ // user back on the sign-in button as if the click did nothing. The captured session is
63
+ // already stripped from the URL, so recovery is a fresh "Sign in via mothership".
64
+ mothershipError.value =
65
+ err instanceof Error ? err.message : 'Could not connect to the mothership'
66
+ }
67
+ return true
68
+ }
69
+
70
+ /**
71
+ * Mothership mode: sign in through the hosted mothership. The mothership owns identity + the
72
+ * allowlist, so we send the browser to ITS OAuth and return here flagged for the connect
73
+ * exchange ({@link maybeConnectMothership}). No-op if the mothership URL isn't known.
74
+ */
75
+ function signInViaMothership() {
76
+ if (typeof window === 'undefined') return
77
+ const base = localMode.value?.mothershipUrl
78
+ if (!base) return
79
+ mothershipError.value = null
80
+ const here = new URL(window.location.origin + window.location.pathname)
81
+ here.searchParams.set('mothership_connect', '1')
82
+ const redirect = new URLSearchParams({ redirect: here.toString() })
83
+ window.location.href = `${base.replace(/\/$/, '')}/auth/login?${redirect}`
84
+ }
85
+
86
+ /** Redeem an `?invite=` token in the URL for the signed-in user, then clean the URL. */
87
+ async function maybeAcceptInvite() {
88
+ if (typeof window === 'undefined') return
89
+ const params = new URLSearchParams(window.location.search)
90
+ const inviteToken = params.get('invite')
91
+ if (!inviteToken) return
92
+ try {
93
+ await api.acceptInvite(inviteToken)
94
+ } catch {
95
+ // Stale/already-accepted invite — ignore and let the app load normally.
96
+ }
97
+ params.delete('invite')
98
+ const qs = params.toString()
99
+ history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
100
+ }
101
+
102
+ return {
103
+ consumeRedirectToken,
104
+ maybeConnectMothership,
105
+ signInViaMothership,
106
+ maybeAcceptInvite,
107
+ }
108
+ }
@@ -0,0 +1,118 @@
1
+ import type { Ref } from 'vue'
2
+ import type { AuthUser } from '~/types/domain'
3
+
4
+ /**
5
+ * Shared reactive state + injected dependencies the auth-store sign-in factory closes over.
6
+ * Created once in the `auth` store setup and threaded into {@link createAuthSessionActions} so
7
+ * the split operations stay behaviourally identical to the original single-closure store — a
8
+ * size-only extraction mirroring `stores/board/` and `stores/pipelines/`, not a new seam.
9
+ */
10
+ export interface AuthSessionContext {
11
+ api: ReturnType<typeof useApi>
12
+ /** The backend's base URL, for the browser-navigating OAuth entry points. */
13
+ apiBase: string
14
+ token: Ref<string | null>
15
+ user: Ref<AuthUser | null>
16
+ autoLoginProvider: Ref<'github' | 'gitlab' | null>
17
+ }
18
+
19
+ /**
20
+ * The sign-in / sign-out operations: the browser-navigating OAuth entry points, the
21
+ * email-password + PAT credential flows, and the session teardown paths (explicit logout vs the
22
+ * API client's 401 handler, which differ in whether they forget the remembered PAT provider).
23
+ */
24
+ export function createAuthSessionActions(ctx: AuthSessionContext) {
25
+ const { api, apiBase, token, user, autoLoginProvider } = ctx
26
+
27
+ /** Build a post-login redirect back to the current page, with an optional invite. */
28
+ function redirectTarget(invite?: string): string {
29
+ const here = window.location.origin + window.location.pathname
30
+ const params = new URLSearchParams({ redirect: here })
31
+ if (invite) params.set('invite', invite)
32
+ return params.toString()
33
+ }
34
+
35
+ /** Send the browser to the backend's GitHub login, returning here after. */
36
+ function login(invite?: string) {
37
+ if (typeof window === 'undefined') return
38
+ window.location.href = `${apiBase}/auth/login?${redirectTarget(invite)}`
39
+ }
40
+
41
+ /** Send the browser to the backend's Google login, returning here after. */
42
+ function loginWithGoogle(invite?: string) {
43
+ if (typeof window === 'undefined') return
44
+ window.location.href = `${apiBase}/auth/google/login?${redirectTarget(invite)}`
45
+ }
46
+
47
+ /** Apply a freshly-minted token + user (from password signup/login). */
48
+ function applySession(result: { token: string; user: AuthUser }) {
49
+ token.value = result.token
50
+ user.value = result.user
51
+ }
52
+
53
+ /** Register a new email/password user (optionally redeeming an invite). */
54
+ async function signup(body: { email: string; password: string; name?: string; invite?: string }) {
55
+ applySession(await api.signup(body))
56
+ }
57
+
58
+ /** Sign in with email/password. */
59
+ async function passwordLogin(body: { email: string; password: string }) {
60
+ applySession(await api.passwordLogin(body))
61
+ }
62
+
63
+ /**
64
+ * Local mode: sign in as the account a source-control PAT belongs to. `token` omitted
65
+ * uses the server-configured PAT (one-click); otherwise a pasted token. Resolves to the
66
+ * SAME canonical user as GitHub OAuth would (keyed on the provider's numeric id).
67
+ */
68
+ async function patLogin(body: { provider: 'github' | 'gitlab'; token?: string }) {
69
+ applySession(await api.patLogin(body))
70
+ // Remember the choice so a later load re-mints the session from the env PAT silently.
71
+ autoLoginProvider.value = body.provider
72
+ }
73
+
74
+ /** Request a password-reset link by email (always resolves; never reveals existence). */
75
+ async function forgotPassword(email: string) {
76
+ await api.forgotPassword({ email })
77
+ }
78
+
79
+ /** Redeem a reset token and set a new password. Throws on an invalid/expired token. */
80
+ async function resetPassword(token: string, password: string) {
81
+ await api.resetPassword({ token, password })
82
+ }
83
+
84
+ /** Drop the local session (sessions are stateless server-side). */
85
+ function logout() {
86
+ api.logout().catch(() => {})
87
+ token.value = null
88
+ user.value = null
89
+ // Forget the remembered provider so logout sticks (otherwise bootstrap would
90
+ // immediately re-mint a session from the env PAT).
91
+ autoLoginProvider.value = null
92
+ }
93
+
94
+ /**
95
+ * Called by the API client when a request comes back 401. Drops the dead session but KEEPS
96
+ * the remembered provider (unlike logout): a 401 from an expired/rotated token or a
97
+ * transient blip should let the next load silently re-mint from the env PAT, not force the
98
+ * login screen. The guarded re-mint in `bootstrap` clears the choice itself if it genuinely
99
+ * fails (PAT removed/revoked), so there's no re-login loop.
100
+ */
101
+ function handleUnauthorized() {
102
+ token.value = null
103
+ user.value = null
104
+ }
105
+
106
+ return {
107
+ applySession,
108
+ login,
109
+ loginWithGoogle,
110
+ signup,
111
+ passwordLogin,
112
+ patLogin,
113
+ forgotPassword,
114
+ resetPassword,
115
+ logout,
116
+ handleUnauthorized,
117
+ }
118
+ }
@@ -7,6 +7,8 @@ import { defineStore } from 'pinia'
7
7
  import { computed, ref } from 'vue'
8
8
  import type { AuthUser } from '~/types/domain'
9
9
  import { retryWhileBackendUnreachable } from '~/utils/backendReady'
10
+ import { createAuthSessionActions } from '~/stores/auth/session'
11
+ import { createAuthRedirectActions } from '~/stores/auth/mothership'
10
12
 
11
13
  /**
12
14
  * "Login with GitHub" session state. The backend mints a signed session token
@@ -121,63 +123,19 @@ export const useAuthStore = defineStore(
121
123
  return true
122
124
  })
123
125
 
124
- /** Pull a token handed back in the post-login URL fragment (#token=…). */
125
- function consumeRedirectToken() {
126
- if (typeof window === 'undefined') return
127
- const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
128
- if (!match) return
129
- token.value = decodeURIComponent(match[1]!)
130
- // Strip the token from the URL so it isn't left in history or shared.
131
- history.replaceState(null, '', window.location.pathname + window.location.search)
132
- }
133
-
134
- /**
135
- * Mothership mode: when the mothership OAuth redirect returns here (flagged
136
- * `?mothership_connect=1`), the URL fragment carries a MOTHERSHIP session — not a local one.
137
- * Hand it to our OWN node, which exchanges it for a cached machine token and returns a LOCAL
138
- * session for the same user. Returns true when it handled the redirect (so the caller skips
139
- * the normal `consumeRedirectToken`, which would wrongly store the mothership session locally).
140
- */
141
- async function maybeConnectMothership(): Promise<boolean> {
142
- if (typeof window === 'undefined') return false
143
- const params = new URLSearchParams(window.location.search)
144
- if (params.get('mothership_connect') !== '1') return false
145
- const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
146
- const session = match ? decodeURIComponent(match[1]!) : null
147
- // Clean the flag + fragment from the URL regardless of outcome, so it isn't left in history.
148
- params.delete('mothership_connect')
149
- const qs = params.toString()
150
- history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
151
- if (!session) return true
152
- try {
153
- const result = await api.connectMothership(session)
154
- applySession({ token: result.session, user: result.user })
155
- mothershipError.value = null
156
- } catch (err) {
157
- // Surface the failure so the login screen shows it, rather than silently dropping the
158
- // user back on the sign-in button as if the click did nothing. The captured session is
159
- // already stripped from the URL, so recovery is a fresh "Sign in via mothership".
160
- mothershipError.value =
161
- err instanceof Error ? err.message : 'Could not connect to the mothership'
162
- }
163
- return true
164
- }
165
-
166
- /**
167
- * Mothership mode: sign in through the hosted mothership. The mothership owns identity + the
168
- * allowlist, so we send the browser to ITS OAuth and return here flagged for the connect
169
- * exchange (`maybeConnectMothership`). No-op if the mothership URL isn't known.
170
- */
171
- function signInViaMothership() {
172
- if (typeof window === 'undefined') return
173
- const base = localMode.value?.mothershipUrl
174
- if (!base) return
175
- mothershipError.value = null
176
- const here = new URL(window.location.origin + window.location.pathname)
177
- here.searchParams.set('mothership_connect', '1')
178
- const redirect = new URLSearchParams({ redirect: here.toString() })
179
- window.location.href = `${base.replace(/\/$/, '')}/auth/login?${redirect}`
180
- }
126
+ // The sign-in / sign-out operations and the redirect-fragment handling, split into cohesive
127
+ // factories sharing the state above (a size-only extraction mirroring `stores/board/` —
128
+ // behaviour is identical to the former in-closure functions). The redirect factory applies a
129
+ // mothership-exchanged session through the session factory's setter.
130
+ const { applySession, ...session } = createAuthSessionActions({
131
+ api,
132
+ apiBase,
133
+ token,
134
+ user,
135
+ autoLoginProvider,
136
+ })
137
+ const { consumeRedirectToken, maybeConnectMothership, signInViaMothership, maybeAcceptInvite } =
138
+ createAuthRedirectActions({ api, token, localMode, mothershipError, applySession })
181
139
 
182
140
  /** Resolve auth state: capture any redirect token, then check the backend. */
183
141
  async function bootstrap() {
@@ -237,7 +195,7 @@ export const useAuthStore = defineStore(
237
195
  localMode.value.patLogin?.configured.includes(autoLoginProvider.value)
238
196
  ) {
239
197
  try {
240
- await patLogin({ provider: autoLoginProvider.value })
198
+ await session.patLogin({ provider: autoLoginProvider.value })
241
199
  } catch {
242
200
  autoLoginProvider.value = null
243
201
  }
@@ -248,106 +206,6 @@ export const useAuthStore = defineStore(
248
206
  ready.value = true
249
207
  }
250
208
 
251
- /** Redeem an `?invite=` token in the URL for the signed-in user, then clean the URL. */
252
- async function maybeAcceptInvite() {
253
- if (typeof window === 'undefined') return
254
- const params = new URLSearchParams(window.location.search)
255
- const inviteToken = params.get('invite')
256
- if (!inviteToken) return
257
- try {
258
- await api.acceptInvite(inviteToken)
259
- } catch {
260
- // Stale/already-accepted invite — ignore and let the app load normally.
261
- }
262
- params.delete('invite')
263
- const qs = params.toString()
264
- history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
265
- }
266
-
267
- /** Build a post-login redirect back to the current page, with an optional invite. */
268
- function redirectTarget(invite?: string): string {
269
- const here = window.location.origin + window.location.pathname
270
- const params = new URLSearchParams({ redirect: here })
271
- if (invite) params.set('invite', invite)
272
- return params.toString()
273
- }
274
-
275
- /** Send the browser to the backend's GitHub login, returning here after. */
276
- function login(invite?: string) {
277
- if (typeof window === 'undefined') return
278
- window.location.href = `${apiBase}/auth/login?${redirectTarget(invite)}`
279
- }
280
-
281
- /** Send the browser to the backend's Google login, returning here after. */
282
- function loginWithGoogle(invite?: string) {
283
- if (typeof window === 'undefined') return
284
- window.location.href = `${apiBase}/auth/google/login?${redirectTarget(invite)}`
285
- }
286
-
287
- /** Apply a freshly-minted token + user (from password signup/login). */
288
- function applySession(result: { token: string; user: AuthUser }) {
289
- token.value = result.token
290
- user.value = result.user
291
- }
292
-
293
- /** Register a new email/password user (optionally redeeming an invite). */
294
- async function signup(body: {
295
- email: string
296
- password: string
297
- name?: string
298
- invite?: string
299
- }) {
300
- applySession(await api.signup(body))
301
- }
302
-
303
- /** Sign in with email/password. */
304
- async function passwordLogin(body: { email: string; password: string }) {
305
- applySession(await api.passwordLogin(body))
306
- }
307
-
308
- /**
309
- * Local mode: sign in as the account a source-control PAT belongs to. `token` omitted
310
- * uses the server-configured PAT (one-click); otherwise a pasted token. Resolves to the
311
- * SAME canonical user as GitHub OAuth would (keyed on the provider's numeric id).
312
- */
313
- async function patLogin(body: { provider: 'github' | 'gitlab'; token?: string }) {
314
- applySession(await api.patLogin(body))
315
- // Remember the choice so a later load re-mints the session from the env PAT silently.
316
- autoLoginProvider.value = body.provider
317
- }
318
-
319
- /** Request a password-reset link by email (always resolves; never reveals existence). */
320
- async function forgotPassword(email: string) {
321
- await api.forgotPassword({ email })
322
- }
323
-
324
- /** Redeem a reset token and set a new password. Throws on an invalid/expired token. */
325
- async function resetPassword(token: string, password: string) {
326
- await api.resetPassword({ token, password })
327
- }
328
-
329
- /** Drop the local session (sessions are stateless server-side). */
330
- function logout() {
331
- api.logout().catch(() => {})
332
- token.value = null
333
- user.value = null
334
- // Forget the remembered provider so logout sticks (otherwise bootstrap would
335
- // immediately re-mint a session from the env PAT).
336
- autoLoginProvider.value = null
337
- }
338
-
339
- /**
340
- * Called by the API client when a request comes back 401. Drops the dead session but KEEPS
341
- * the remembered provider (unlike logout): a 401 from an expired/rotated token or a
342
- * transient blip should let the next load silently re-mint from the env PAT, not force the
343
- * login screen. The guarded re-mint in `bootstrap` clears the choice itself if it genuinely
344
- * fails (PAT removed/revoked), so there's no re-login loop.
345
- */
346
- function handleUnauthorized() {
347
- token.value = null
348
- user.value = null
349
- }
350
-
351
209
  return {
352
210
  token,
353
211
  user,
@@ -367,16 +225,8 @@ export const useAuthStore = defineStore(
367
225
  isMisconfigured,
368
226
  needsLogin,
369
227
  bootstrap,
370
- login,
371
- loginWithGoogle,
372
228
  signInViaMothership,
373
- signup,
374
- passwordLogin,
375
- patLogin,
376
- forgotPassword,
377
- resetPassword,
378
- logout,
379
- handleUnauthorized,
229
+ ...session,
380
230
  }
381
231
  },
382
232
  { persist: { pick: ['token', 'autoLoginProvider'] } },
@@ -1,4 +1,3 @@
1
- import type { UpdateBlockInput } from '@cat-factory/contracts'
2
1
  import type {
3
2
  BlockType,
4
3
  CreateTaskType,
@@ -6,16 +5,16 @@ import type {
6
5
  TaskTypeFields,
7
6
  Block,
8
7
  } from '~/types/domain'
9
- import { useServicesStore } from '~/stores/services'
10
8
  import { useWorkspaceStore } from '~/stores/workspace'
11
9
  import type { BoardWriteContext } from './context'
12
- import { UNDO_WINDOW_MS } from './context'
13
10
 
14
11
  /**
15
- * The board's create / move / update / dependency write operations, extracted from the store
16
- * setup. Each closes over the shared {@link BoardWriteContext} (the authoritative block returned
17
- * by the API is applied via `upsert`) so behaviour is identical to the original in-closure
18
- * functions — the split is purely to keep every function within the size budget.
12
+ * The board's creation writes (services / modules / tasks / epics) plus the archive-restore
13
+ * lifecycle, extracted from the store setup. Each closes over the shared
14
+ * {@link BoardWriteContext} (the authoritative block returned by the API is applied via `upsert`)
15
+ * so behaviour is identical to the original in-closure functions — the split is purely to keep
16
+ * every function within the size budget. The placement + edit writes live alongside in
17
+ * {@link createBoardPlacement}.
19
18
  */
20
19
  export function createBoardMutations(ctx: BoardWriteContext) {
21
20
  const { getBlock, upsert, api, toast, tr } = ctx
@@ -152,69 +151,6 @@ export function createBoardMutations(ctx: BoardWriteContext) {
152
151
  return block
153
152
  }
154
153
 
155
- /**
156
- * Move a block into a new container at a new local position. Drag-reparent commits
157
- * silently on a small overshoot, so a successful move (into a *different* container,
158
- * not an undo of one) offers a one-click undo back to its previous home.
159
- */
160
- async function reparentBlock(
161
- id: string,
162
- newParentId: string,
163
- position: { x: number; y: number },
164
- opts: { undoable?: boolean } = { undoable: true },
165
- ) {
166
- const b = getBlock(id)
167
- const parent = getBlock(newParentId)
168
- if (!b || !parent || b.id === newParentId) return
169
- // tasks may live in services or modules; modules only in services
170
- if (b.level === 'task' && parent.level !== 'frame' && parent.level !== 'module') return
171
- if (b.level === 'module' && parent.level !== 'frame') return
172
- // Optimistic: drop the block into the new container immediately so it doesn't
173
- // briefly snap back to its old home while the request is in flight. Snapshot
174
- // the old home so a rejected reparent restores it rather than leaving the
175
- // block in the wrong container (a structural lie that survives until re-hydrate).
176
- const prevParentId = b.parentId
177
- const prevPosition = b.position
178
- const name = b.title
179
- b.parentId = newParentId
180
- b.position = position
181
- try {
182
- upsert(
183
- await api.reparentBlock(useWorkspaceStore().requireId(), id, {
184
- parentId: newParentId,
185
- position,
186
- }),
187
- )
188
- // Offer an undo back to the previous container (a drag overshoot is easy). The undo
189
- // move is itself non-undoable so the toast doesn't ping-pong.
190
- if (opts.undoable && prevParentId) {
191
- toast.add({
192
- title: tr('board.toast.moved', { name }),
193
- icon: 'i-lucide-move',
194
- color: 'neutral',
195
- duration: UNDO_WINDOW_MS,
196
- actions: [
197
- {
198
- label: tr('common.undo'),
199
- icon: 'i-lucide-undo-2',
200
- onClick: () =>
201
- void reparentBlock(id, prevParentId, prevPosition, { undoable: false }),
202
- },
203
- ],
204
- })
205
- }
206
- } catch (e) {
207
- b.parentId = prevParentId
208
- b.position = prevPosition
209
- toast.add({
210
- title: tr('board.toast.moveFailed'),
211
- description: e instanceof Error ? e.message : String(e),
212
- icon: 'i-lucide-triangle-alert',
213
- color: 'error',
214
- })
215
- }
216
- }
217
-
218
154
  /**
219
155
  * Archive a service (hide it + its subtree, restorable with no expiry) — the non-destructive
220
156
  * alternative to deleting a service that still has unfinished tasks. The acting tab isn't
@@ -232,121 +168,6 @@ export function createBoardMutations(ctx: BoardWriteContext) {
232
168
  await useWorkspaceStore().refresh()
233
169
  }
234
170
 
235
- /**
236
- * Local-only optimistic position update during an active drag — no persistence.
237
- * A drag fires this on every pointer move so the block tracks the cursor without
238
- * a per-move API round-trip; the final position is committed once via
239
- * {@link moveBlock} (or {@link reparentBlock}) on release. Persisting every move
240
- * raced: out-of-order responses to the burst of in-flight writes could land a
241
- * stale position last, snapping the block back after the user let go.
242
- */
243
- function previewMove(id: string, position: { x: number; y: number }) {
244
- const b = getBlock(id)
245
- if (b) b.position = position
246
- }
247
-
248
- async function moveBlock(id: string, position: { x: number; y: number }) {
249
- const b = getBlock(id)
250
- if (!b) return
251
- const prevPosition = b.position
252
- b.position = position // optimistic: keep the drag feeling instant
253
- try {
254
- // A mounted service frame's position is a PER-WORKSPACE layout override on the mount, not
255
- // on the (shared) block — so route a frame drag there. Other moves write the block.
256
- const services = useServicesStore()
257
- const mount = services.serviceByFrameBlock[id]
258
- ? services.byServiceId[services.serviceByFrameBlock[id]!.id]
259
- : undefined
260
- if (mount) {
261
- await services.updateLayout(mount.serviceId, position)
262
- return
263
- }
264
- upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
265
- } catch (e) {
266
- // Restore the pre-drag position — a rejected move must not leave the block at a
267
- // spot the server never stored (a lie that survives until the next re-hydrate).
268
- b.position = prevPosition
269
- toast.add({
270
- title: tr('board.toast.moveFailed'),
271
- description: e instanceof Error ? e.message : String(e),
272
- icon: 'i-lucide-triangle-alert',
273
- color: 'error',
274
- })
275
- }
276
- }
277
-
278
- /** Patch the user-editable fields of a block (title, features, threshold…). */
279
- async function updateBlock(id: string, patch: UpdateBlockInput) {
280
- const b = getBlock(id)
281
- if (!b) return
282
- // Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
283
- // (a patch may set several at once) rather than leaving a stale optimistic value stuck on
284
- // screen with no feedback — the same rollback contract the other mutations here follow.
285
- const prev: Record<string, unknown> = {}
286
- const patchRecord = patch as Record<string, unknown>
287
- const record = b as unknown as Record<string, unknown>
288
- for (const key of Object.keys(patch)) prev[key] = record[key]
289
- Object.assign(b, patch) // optimistic
290
- try {
291
- upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
292
- } catch (e) {
293
- // Re-resolve the block: a live event may have replaced its object reference (`upsert`
294
- // swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
295
- // fields that still hold OUR optimistic value, so a newer server value that landed
296
- // mid-flight isn't clobbered by the rollback.
297
- const cur = getBlock(id) as unknown as Record<string, unknown> | undefined
298
- if (cur) {
299
- for (const key of Object.keys(patch)) {
300
- if (cur[key] === patchRecord[key]) cur[key] = prev[key]
301
- }
302
- }
303
- toast.add({
304
- title: tr('board.toast.updateFailed'),
305
- description: e instanceof Error ? e.message : String(e),
306
- icon: 'i-lucide-triangle-alert',
307
- color: 'error',
308
- })
309
- }
310
- }
311
-
312
- /**
313
- * Toggle a dependency edge target -> source (target dependsOn source). The backend
314
- * rejects an edge that would close a cycle (422) — surface that as a toast rather than
315
- * letting it throw unhandled out of a board gesture.
316
- */
317
- async function toggleDependency(targetId: string, sourceId: string) {
318
- if (targetId === sourceId || !getBlock(targetId)) return
319
- try {
320
- upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
321
- } catch (e) {
322
- toast.add({
323
- title: tr('board.toast.linkFailed'),
324
- description: e instanceof Error ? e.message : String(e),
325
- icon: 'i-lucide-triangle-alert',
326
- color: 'error',
327
- })
328
- }
329
- }
330
-
331
- /** Remove a dependency edge target -> source if it exists. */
332
- async function removeDependency(targetId: string, sourceId: string) {
333
- const t = getBlock(targetId)
334
- if (!t || !t.dependsOn.includes(sourceId)) return
335
- // the backend exposes a single toggle; the edge exists, so toggling removes it
336
- try {
337
- upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
338
- } catch (e) {
339
- // Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
340
- // than rejecting unhandled with no feedback.
341
- toast.add({
342
- title: tr('board.toast.unlinkFailed'),
343
- description: e instanceof Error ? e.message : String(e),
344
- icon: 'i-lucide-triangle-alert',
345
- color: 'error',
346
- })
347
- }
348
- }
349
-
350
171
  return {
351
172
  addBlock,
352
173
  addServiceFromRepo,
@@ -354,13 +175,7 @@ export function createBoardMutations(ctx: BoardWriteContext) {
354
175
  addModule,
355
176
  addEpic,
356
177
  assignToEpic,
357
- reparentBlock,
358
178
  archiveService,
359
179
  restoreService,
360
- previewMove,
361
- moveBlock,
362
- updateBlock,
363
- toggleDependency,
364
- removeDependency,
365
180
  }
366
181
  }