From 256ebb3b1190ff7700555b11e27a03fb293a22ab Mon Sep 17 00:00:00 2001 From: BBAKJUN Date: Mon, 30 Mar 2026 10:48:00 +0900 Subject: [PATCH] feat(approval): connect desktop approval actions to API --- apps/api/src/application/approval/useCases.ts | 27 ++- .../data/postgresWorkspaceSessionSource.ts | 15 +- apps/desktop/src/domain/approvals.ts | 20 +- .../src/hooks/approvalActionUtils.test.ts | 120 +++++++++++ apps/desktop/src/hooks/approvalActionUtils.ts | 115 +++++++++++ apps/desktop/src/hooks/useApprovalsPage.ts | 178 ++++++++++++++++- apps/desktop/src/pages/ApprovalsPage.tsx | 188 +++++++++++++----- apps/desktop/src/queries/queryKeys.ts | 4 + ...paceId.documents.$documentId.approvals.tsx | 11 +- .../src/services/rpcApprovalService.ts | 70 +++++++ .../services/sessionBackedApprovalService.ts | 19 +- .../src/services/tauriHarnessDocsServices.ts | 7 +- apps/desktop/src/types/contracts.ts | 3 + 13 files changed, 719 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/src/hooks/approvalActionUtils.test.ts create mode 100644 apps/desktop/src/hooks/approvalActionUtils.ts create mode 100644 apps/desktop/src/services/rpcApprovalService.ts diff --git a/apps/api/src/application/approval/useCases.ts b/apps/api/src/application/approval/useCases.ts index 496df49..b3bc638 100644 --- a/apps/api/src/application/approval/useCases.ts +++ b/apps/api/src/application/approval/useCases.ts @@ -39,6 +39,18 @@ export function createApprovalUseCases({ dataSource }: ApprovalUseCaseDependenci return documentNotFoundFailure(documentId); } + if ( + input.membershipId && + input.requestedByMembershipId && + input.membershipId === input.requestedByMembershipId + ) { + return fail( + 422, + "approval_self_request_not_allowed", + "Approval requester and reviewer must be different memberships.", + ); + } + const mutation = await dataSource.requestApproval(workspaceId, documentId, input); if (!mutation) { @@ -58,10 +70,23 @@ export function createApprovalUseCases({ dataSource }: ApprovalUseCaseDependenci return workspaceNotFoundFailure(workspaceId); } - if (!hasEntityWithId(approvals, approvalId)) { + const targetApproval = approvals.find((approval) => approval.id === approvalId); + + if (!targetApproval) { return approvalNotFoundFailure(approvalId); } + if ( + !targetApproval.membershipId || + targetApproval.membershipId !== input.decisionByMembershipId + ) { + return fail( + 422, + "approval_decision_forbidden", + "Only the assigned reviewer membership can record this approval decision.", + ); + } + const mutation = await dataSource.decideApproval(workspaceId, approvalId, input); if (!mutation) { diff --git a/apps/api/src/infrastructure/data/postgresWorkspaceSessionSource.ts b/apps/api/src/infrastructure/data/postgresWorkspaceSessionSource.ts index 5ef8bf9..4ab6486 100644 --- a/apps/api/src/infrastructure/data/postgresWorkspaceSessionSource.ts +++ b/apps/api/src/infrastructure/data/postgresWorkspaceSessionSource.ts @@ -1628,6 +1628,14 @@ export function createPostgresWorkspaceSessionSource( return null; } + if ( + input.membershipId && + input.requestedByMembershipId && + input.membershipId === input.requestedByMembershipId + ) { + return null; + } + const timestamp = nowIso(); const nextApprovalId = buildId("apr"); @@ -1712,7 +1720,12 @@ export function createPostgresWorkspaceSessionSource( .limit(1) .then((rows: MembershipRow[]) => rows[0] ?? null); - if (!approvalRow || !decisionMembership) { + if ( + !approvalRow || + !decisionMembership || + !approvalRow.membershipId || + approvalRow.membershipId !== input.decisionByMembershipId + ) { return null; } diff --git a/apps/desktop/src/domain/approvals.ts b/apps/desktop/src/domain/approvals.ts index c7d24ed..1e25d6c 100644 --- a/apps/desktop/src/domain/approvals.ts +++ b/apps/desktop/src/domain/approvals.ts @@ -1,12 +1,15 @@ import type { + ApprovalDecisionDto, + ApprovalRequestDto, ApprovalCandidateSource, DocumentApproval, DocumentApprovalAuthority, DocumentInvalidation, DocumentReviewState, UnresolvedApprovalSnapshot, + WorkspaceGraph, } from "../types/contracts"; -import type { DocumentId, MembershipId, WorkspaceId } from "../types/domain-ui"; +import type { ApprovalId, DocumentId, MembershipId, WorkspaceId } from "../types/domain-ui"; export interface ApprovalAuthorityRestorationPolicy { restoredBy: DocumentApprovalAuthority; @@ -32,6 +35,11 @@ export interface DocumentApprovalBundle { unresolvedApprovals: UnresolvedApprovalSnapshot[]; } +export interface ApprovalMutationResult { + approval: DocumentApproval; + workspaceGraph: WorkspaceGraph; +} + export interface ApprovalService { getWorkspacePolicy: (workspaceId: WorkspaceId) => Promise; listDocumentApprovalBundles: (workspaceId: WorkspaceId) => Promise; @@ -39,4 +47,14 @@ export interface ApprovalService { workspaceId: WorkspaceId, documentId: DocumentId, ) => Promise; + requestApproval: ( + workspaceId: WorkspaceId, + documentId: DocumentId, + input: ApprovalRequestDto, + ) => Promise; + decideApproval: ( + workspaceId: WorkspaceId, + approvalId: ApprovalId, + input: ApprovalDecisionDto, + ) => Promise; } diff --git a/apps/desktop/src/hooks/approvalActionUtils.test.ts b/apps/desktop/src/hooks/approvalActionUtils.test.ts new file mode 100644 index 0000000..09e6323 --- /dev/null +++ b/apps/desktop/src/hooks/approvalActionUtils.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { DocumentApproval } from "../types/contracts"; +import { + createWorkspaceGraphFixture, + createWorkspaceMembershipFixture, +} from "../test/workspaceGraphFixtures"; +import { + canCurrentMembershipDecideApproval, + hasOpenApprovalForMembership, + pickSuggestedApprovalReviewer, +} from "./approvalActionUtils"; + +function createApprovalFixture(overrides: Partial = {}): DocumentApproval { + return { + id: "apr-1", + workspaceId: "ws-1", + documentId: "doc-1", + authority: "lead", + source: "workspace_membership", + membershipId: "mem-1", + githubCandidateLogin: null, + reviewerLabel: "Sarah Chen", + requestedByMembershipId: "mem-2", + decision: null, + decisionByMembershipId: null, + restorationByMembershipId: null, + restoredFromApprovalId: null, + invalidatedByDocumentId: null, + decisionNote: null, + lifecycle: { + createdAt: "2026-03-29T00:00:00.000Z", + updatedAt: "2026-03-29T00:00:00.000Z", + state: "pending", + requestedAt: "2026-03-29T00:00:00.000Z", + respondedAt: null, + invalidatedAt: null, + restoredAt: null, + }, + ...overrides, + }; +} + +describe("approvalActionUtils", () => { + it("prefers an active reviewer when the lead is the current membership", () => { + const graph = createWorkspaceGraphFixture({ + memberships: [ + createWorkspaceMembershipFixture({ + id: "mem-1", + userId: "usr_mina_cho", + role: "Lead", + }), + createWorkspaceMembershipFixture({ + id: "mem-2", + userId: "usr_sam_kim", + role: "Reviewer", + }), + ], + workspace: createWorkspaceGraphFixture().workspace, + }); + + const reviewer = pickSuggestedApprovalReviewer(graph, "mem-1"); + + expect(reviewer).toEqual({ + membershipId: "mem-2", + reviewerLabel: "Aisha Patel", + authority: "required_reviewer", + }); + }); + + it("returns null when only the current membership is available", () => { + const graph = createWorkspaceGraphFixture({ + memberships: [ + createWorkspaceMembershipFixture({ + id: "mem-1", + userId: "usr_mina_cho", + role: "Lead", + }), + ], + }); + + expect(pickSuggestedApprovalReviewer(graph, "mem-1")).toBeNull(); + }); + + it("allows decisions only for the current reviewer on actionable states", () => { + expect(canCurrentMembershipDecideApproval(createApprovalFixture(), "mem-1")).toBe(true); + expect( + canCurrentMembershipDecideApproval(createApprovalFixture({ membershipId: "mem-2" }), "mem-1"), + ).toBe(false); + expect( + canCurrentMembershipDecideApproval( + createApprovalFixture({ + lifecycle: { + ...createApprovalFixture().lifecycle, + state: "approved", + }, + decision: "approved", + }), + "mem-1", + ), + ).toBe(false); + }); + + it("detects existing open approvals for the same reviewer", () => { + expect(hasOpenApprovalForMembership([createApprovalFixture()], "mem-1")).toBe(true); + expect( + hasOpenApprovalForMembership( + [ + createApprovalFixture({ + lifecycle: { + ...createApprovalFixture().lifecycle, + state: "approved", + }, + decision: "approved", + }), + ], + "mem-1", + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/hooks/approvalActionUtils.ts b/apps/desktop/src/hooks/approvalActionUtils.ts new file mode 100644 index 0000000..bdc2b9d --- /dev/null +++ b/apps/desktop/src/hooks/approvalActionUtils.ts @@ -0,0 +1,115 @@ +import type { + DocumentApproval, + DocumentApprovalAuthority, + WorkspaceGraph, +} from "../types/contracts"; +import type { MembershipId } from "../types/domain-ui"; +import { getMemberSummaryByMembershipId } from "../view-models/memberSummaries"; + +export interface SuggestedApprovalReviewer { + membershipId: MembershipId; + reviewerLabel: string; + authority: DocumentApprovalAuthority; +} + +function isActiveMembership(graph: WorkspaceGraph, membershipId: MembershipId | null | undefined) { + if (!membershipId) { + return false; + } + + return graph.memberships.some( + (membership) => membership.id === membershipId && membership.lifecycle.status === "active", + ); +} + +function buildOrderedMembershipIds( + graph: WorkspaceGraph, + activeMembershipId: MembershipId | null, +): MembershipId[] { + const activeMembershipIds = graph.memberships + .filter((membership) => membership.lifecycle.status === "active") + .map((membership) => membership.id); + const reviewerIds = graph.memberships + .filter( + (membership) => + membership.lifecycle.status === "active" && + membership.role === "Reviewer" && + membership.id !== activeMembershipId, + ) + .map((membership) => membership.id); + const leadId = isActiveMembership(graph, graph.workspace.leadMembershipId) + ? graph.workspace.leadMembershipId + : null; + const otherLeadIds = graph.memberships + .filter( + (membership) => + membership.lifecycle.status === "active" && + membership.role === "Lead" && + membership.id !== leadId && + membership.id !== activeMembershipId, + ) + .map((membership) => membership.id); + const editorIds = graph.memberships + .filter( + (membership) => + membership.lifecycle.status === "active" && + membership.role === "Editor" && + membership.id !== activeMembershipId, + ) + .map((membership) => membership.id); + + return Array.from( + new Set([ + ...(leadId && leadId !== activeMembershipId ? [leadId] : []), + ...reviewerIds, + ...otherLeadIds, + ...editorIds, + ]), + ); +} + +export function pickSuggestedApprovalReviewer( + graph: WorkspaceGraph, + activeMembershipId: MembershipId | null, +): SuggestedApprovalReviewer | null { + const membershipId = buildOrderedMembershipIds(graph, activeMembershipId)[0] ?? null; + + if (!membershipId) { + return null; + } + + const membership = graph.memberships.find((entry) => entry.id === membershipId) ?? null; + const summary = getMemberSummaryByMembershipId(graph, membershipId); + + if (!membership || !summary) { + return null; + } + + return { + membershipId, + reviewerLabel: summary.name, + authority: membership.role === "Lead" ? "lead" : "required_reviewer", + }; +} + +export function canCurrentMembershipDecideApproval( + approval: DocumentApproval, + activeMembershipId: MembershipId | null, +) { + return ( + approval.membershipId != null && + approval.membershipId === activeMembershipId && + ["pending", "changes_requested", "invalidated"].includes(approval.lifecycle.state) + ); +} + +export function hasOpenApprovalForMembership( + approvals: DocumentApproval[], + membershipId: MembershipId, +) { + return approvals.some( + (approval) => + approval.membershipId === membershipId && + ["pending", "changes_requested", "invalidated"].includes(approval.lifecycle.state), + ); +} diff --git a/apps/desktop/src/hooks/useApprovalsPage.ts b/apps/desktop/src/hooks/useApprovalsPage.ts index c17a781..9a65e04 100644 --- a/apps/desktop/src/hooks/useApprovalsPage.ts +++ b/apps/desktop/src/hooks/useApprovalsPage.ts @@ -1,7 +1,21 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ApprovalDecision, ApprovalRequestDto, DocumentApproval } from "../types/contracts"; +import { desktopMutationKeys, desktopQueryKeys } from "../queries/queryKeys"; import type { WorkspaceShellModel } from "./useWorkspaceShell"; +import { loadBootstrapState } from "./useAppBootstrap"; +import { + canCurrentMembershipDecideApproval, + hasOpenApprovalForMembership, + pickSuggestedApprovalReviewer, +} from "./approvalActionUtils"; +import { showErrorToast, toErrorMessage } from "../lib/errorToast"; + +type ApprovalDecisionAction = Extract; export function useApprovalsPage(shell: WorkspaceShellModel) { + const queryClient = useQueryClient(); + const [decisionTargetApprovalId, setDecisionTargetApprovalId] = useState(null); const approvals = useMemo(() => { if (!shell.activeWorkspaceGraph || !shell.activeDocument) { return []; @@ -11,8 +25,170 @@ export function useApprovalsPage(shell: WorkspaceShellModel) { (approval) => approval.documentId === shell.activeDocument?.id, ); }, [shell.activeDocument, shell.activeWorkspaceGraph]); + const suggestedReviewer = useMemo(() => { + if (!shell.activeWorkspaceGraph) { + return null; + } + + return pickSuggestedApprovalReviewer(shell.activeWorkspaceGraph, shell.activeMembershipId); + }, [shell.activeMembershipId, shell.activeWorkspaceGraph]); + + const refreshBootstrap = async () => { + const nextBootstrap = await loadBootstrapState(shell.services); + queryClient.setQueryData(desktopQueryKeys.bootstrap(), nextBootstrap); + return nextBootstrap; + }; + + const refreshBootstrapAfterMutation = async () => { + try { + await refreshBootstrap(); + } catch (error) { + showErrorToast({ + title: "승인 상태 새로고침 실패", + description: `서버에는 반영되었지만 화면 상태를 다시 읽지 못했습니다. ${toErrorMessage(error)}`, + }); + } + }; + + const requestApprovalMutation = useMutation({ + mutationKey: desktopMutationKeys.approvals.request(), + mutationFn: async ({ + workspaceId, + documentId, + input, + }: { + workspaceId: string; + documentId: string; + input: ApprovalRequestDto; + }) => shell.services.approvals.requestApproval(workspaceId, documentId, input), + onSuccess: async () => { + await refreshBootstrapAfterMutation(); + }, + onError: (error) => { + showErrorToast({ + title: "승인 요청 실패", + description: toErrorMessage(error), + }); + }, + }); + + const decisionMutation = useMutation({ + mutationKey: desktopMutationKeys.approvals.decide(), + mutationFn: async ({ + workspaceId, + approvalId, + decision, + }: { + workspaceId: string; + approvalId: string; + decision: ApprovalDecisionAction; + }) => + shell.services.approvals.decideApproval(workspaceId, approvalId, { + decision, + decisionByMembershipId: shell.activeMembershipId ?? "", + }), + onSuccess: async () => { + await refreshBootstrapAfterMutation(); + }, + onError: (error) => { + showErrorToast({ + title: "승인 결정 실패", + description: toErrorMessage(error), + }); + }, + onSettled: () => { + setDecisionTargetApprovalId(null); + }, + }); + + const hasOpenSuggestedApproval = useMemo(() => { + if (!suggestedReviewer) { + return false; + } + + return hasOpenApprovalForMembership(approvals, suggestedReviewer.membershipId); + }, [approvals, suggestedReviewer]); + + const requestApprovalDisabledReason = + !shell.activeWorkspaceGraph || !shell.activeDocument + ? "문서를 선택해야 승인 요청을 보낼 수 있습니다." + : !shell.activeMembershipId + ? "활성 멤버십이 있어야 승인 요청을 보낼 수 있습니다." + : !suggestedReviewer + ? "요청 가능한 승인자를 찾지 못했습니다." + : hasOpenSuggestedApproval + ? `${suggestedReviewer.reviewerLabel}에게 이미 미해결 승인 요청이 있습니다.` + : requestApprovalMutation.isPending + ? "승인 요청을 보내는 중입니다." + : null; + + const handleRequestApproval = async () => { + if ( + !shell.activeWorkspaceGraph || + !shell.activeDocument || + !shell.activeMembershipId || + !suggestedReviewer || + requestApprovalDisabledReason + ) { + return; + } + + try { + await requestApprovalMutation.mutateAsync({ + workspaceId: shell.activeWorkspaceGraph.workspace.id, + documentId: shell.activeDocument.id, + input: { + authority: suggestedReviewer.authority, + source: "workspace_membership", + reviewerLabel: suggestedReviewer.reviewerLabel, + membershipId: suggestedReviewer.membershipId, + requestedByMembershipId: shell.activeMembershipId, + }, + }); + } catch { + return; + } + }; + + const handleApprovalDecision = async (approvalId: string, decision: ApprovalDecisionAction) => { + if (!shell.activeWorkspaceId || !shell.activeMembershipId || decisionMutation.isPending) { + return; + } + + const targetApproval = approvals.find((approval) => approval.id === approvalId) ?? null; + + if ( + !targetApproval || + !canCurrentMembershipDecideApproval(targetApproval, shell.activeMembershipId) + ) { + return; + } + + setDecisionTargetApprovalId(approvalId); + + try { + await decisionMutation.mutateAsync({ + workspaceId: shell.activeWorkspaceId, + approvalId, + decision, + }); + } catch { + return; + } + }; return { approvals, + requestApprovalLabel: suggestedReviewer + ? `${suggestedReviewer.reviewerLabel}에게 승인 요청` + : "승인 요청", + requestApprovalDisabledReason, + isRequestingApproval: requestApprovalMutation.isPending, + canCurrentMemberDecide: (approval: DocumentApproval) => + canCurrentMembershipDecideApproval(approval, shell.activeMembershipId), + isDecisionPendingFor: (approvalId: string) => + decisionMutation.isPending && decisionTargetApprovalId === approvalId, + handleRequestApproval, + handleApprovalDecision, }; } diff --git a/apps/desktop/src/pages/ApprovalsPage.tsx b/apps/desktop/src/pages/ApprovalsPage.tsx index c2dfc45..84a0768 100644 --- a/apps/desktop/src/pages/ApprovalsPage.tsx +++ b/apps/desktop/src/pages/ApprovalsPage.tsx @@ -9,13 +9,32 @@ import { EmptyStateCard, formatDateTime, statusBadgeVariant, translateLabel } fr export function ApprovalsPage({ app, approvals, + canCurrentMemberDecide, + isDecisionPendingFor, + isRequestingApproval, + onApprovalDecision, onGoToDocuments, onGoToComments, + onRequestApproval, + requestApprovalDisabledReason, + requestApprovalLabel, }: { app: WorkspaceShellModel; approvals: Array["approvals"][number]>; + canCurrentMemberDecide: ( + approval: NonNullable["approvals"][number], + ) => boolean; + isDecisionPendingFor: (approvalId: string) => boolean; + isRequestingApproval: boolean; + onApprovalDecision: ( + approvalId: string, + decision: "approved" | "changes_requested", + ) => Promise; onGoToDocuments: () => void; onGoToComments: () => void; + onRequestApproval: () => Promise; + requestApprovalDisabledReason: string | null; + requestApprovalLabel: string; }) { const { logEvent } = useClientActivityLog(); const graph = app.activeWorkspaceGraph; @@ -107,41 +126,62 @@ export function ApprovalsPage({ /> ) : ( pendingApprovals.map((approval) => ( - + {canCurrentMemberDecide(approval) ? ( +
+ void onApprovalDecision(approval.id, "approved")} + > + 승인 + + void onApprovalDecision(approval.id, "changes_requested")} + > + 수정 요청 + +
+ ) : null} - - + )) )} @@ -183,11 +223,27 @@ export function ApprovalsPage({ - -

- - 집중 문서 -

+ +
+

+ + 집중 문서 +

+ {selectedDocument && requestApprovalDisabledReason ? ( +

+ {requestApprovalDisabledReason} +

+ ) : null} +
+ void onRequestApproval()} + > + {requestApprovalLabel} +
{!selectedDocument ? ( @@ -198,9 +254,16 @@ export function ApprovalsPage({ ) : approvals.length === 0 ? ( + void onRequestApproval()} + > + {requestApprovalLabel} + 리뷰 보기 @@ -213,17 +276,44 @@ export function ApprovalsPage({ ) : ( approvals.map((approval) => (
-
-

- {approval.reviewerLabel} -

- {translateLabel(approval.authority)} - {translateLabel(approval.source)} +
+
+
+

+ {approval.reviewerLabel} +

+ {translateLabel(approval.authority)} + {translateLabel(approval.source)} + + {translateLabel(approval.lifecycle.state)} + +
+

+ 요청 {formatDateTime(approval.lifecycle.requestedAt)} · 상태{" "} + {translateLabel(approval.lifecycle.state)} +

+
+ {canCurrentMemberDecide(approval) ? ( +
+ void onApprovalDecision(approval.id, "approved")} + > + 승인 + + + void onApprovalDecision(approval.id, "changes_requested") + } + > + 수정 요청 + +
+ ) : null}
-

- 요청 {formatDateTime(approval.lifecycle.requestedAt)} · 상태{" "} - {translateLabel(approval.lifecycle.state)} -

)) )} diff --git a/apps/desktop/src/queries/queryKeys.ts b/apps/desktop/src/queries/queryKeys.ts index 15e6e3d..20a4d80 100644 --- a/apps/desktop/src/queries/queryKeys.ts +++ b/apps/desktop/src/queries/queryKeys.ts @@ -27,6 +27,10 @@ export const desktopMutationKeys = { create: () => [...desktopQueryRoot, "workspace", "create"] as const, acceptInvitation: () => [...desktopQueryRoot, "workspace", "accept-invitation"] as const, }, + approvals: { + request: () => [...desktopQueryRoot, "approvals", "request"] as const, + decide: () => [...desktopQueryRoot, "approvals", "decide"] as const, + }, ai: { runEntryPoint: () => [...desktopQueryRoot, "ai", "run-entry-point"] as const, }, diff --git a/apps/desktop/src/routes/$workspaceId.documents.$documentId.approvals.tsx b/apps/desktop/src/routes/$workspaceId.documents.$documentId.approvals.tsx index 8cd4e01..de5d59b 100644 --- a/apps/desktop/src/routes/$workspaceId.documents.$documentId.approvals.tsx +++ b/apps/desktop/src/routes/$workspaceId.documents.$documentId.approvals.tsx @@ -11,14 +11,21 @@ export const Route = createFileRoute("/$workspaceId/documents/$documentId/approv function WorkspaceDocumentApprovalsRoute() { const shell = useWorkspaceRouteShell(); - const approvals = useApprovalsPage(shell); + const approvalsPage = useApprovalsPage(shell); return ( shell.handleAreaChange("comments")} onGoToDocuments={() => shell.handleAreaChange("documents")} + onRequestApproval={approvalsPage.handleRequestApproval} + requestApprovalDisabledReason={approvalsPage.requestApprovalDisabledReason} + requestApprovalLabel={approvalsPage.requestApprovalLabel} /> ); } diff --git a/apps/desktop/src/services/rpcApprovalService.ts b/apps/desktop/src/services/rpcApprovalService.ts new file mode 100644 index 0000000..3fb7030 --- /dev/null +++ b/apps/desktop/src/services/rpcApprovalService.ts @@ -0,0 +1,70 @@ +import type { + ApprovalDecisionDto, + ApprovalMutationEnvelopeDto, + ApprovalRequestDto, +} from "../types/contracts"; +import type { ApprovalService } from "../domain/approvals"; +import { harnessRpcClient } from "../lib/rpc/client"; +import { createAuthorizationHeader, unwrapRpcResponse } from "../lib/rpc/response"; + +interface CreateRpcApprovalServiceOptions { + fallbackService: ApprovalService; + getSessionToken?: () => Promise | string | null; +} + +export function createRpcApprovalService({ + fallbackService, + getSessionToken, +}: CreateRpcApprovalServiceOptions): ApprovalService { + return { + ...fallbackService, + async requestApproval(workspaceId, documentId, input) { + const sessionToken = await getSessionToken?.(); + const response = await harnessRpcClient.api.workspaces[":workspaceId"].documents[ + ":documentId" + ].approvals.$post( + { + param: { workspaceId, documentId }, + json: input, + }, + { + headers: createAuthorizationHeader(sessionToken), + }, + ); + + const payload = await unwrapRpcResponse( + response, + "Approval request failed", + ); + + return { + approval: payload.approval, + workspaceGraph: payload.workspaceGraph, + }; + }, + async decideApproval(workspaceId, approvalId, input) { + const sessionToken = await getSessionToken?.(); + const response = await harnessRpcClient.api.workspaces[":workspaceId"].approvals[ + ":approvalId" + ].$patch( + { + param: { workspaceId, approvalId }, + json: input, + }, + { + headers: createAuthorizationHeader(sessionToken), + }, + ); + + const payload = await unwrapRpcResponse( + response, + "Approval decision failed", + ); + + return { + approval: payload.approval, + workspaceGraph: payload.workspaceGraph, + }; + }, + }; +} diff --git a/apps/desktop/src/services/sessionBackedApprovalService.ts b/apps/desktop/src/services/sessionBackedApprovalService.ts index afcff5e..4518e15 100644 --- a/apps/desktop/src/services/sessionBackedApprovalService.ts +++ b/apps/desktop/src/services/sessionBackedApprovalService.ts @@ -1,10 +1,11 @@ import type { ApprovalService, + ApprovalMutationResult, DocumentApprovalBundle, WorkspaceApprovalPolicy, } from "../domain/approvals"; -import type { WorkspaceGraph } from "../types/contracts"; -import type { DocumentId, WorkspaceId } from "../types/domain-ui"; +import type { ApprovalDecisionDto, ApprovalRequestDto, WorkspaceGraph } from "../types/contracts"; +import type { ApprovalId, DocumentId, WorkspaceId } from "../types/domain-ui"; interface SessionBackedApprovalServiceOptions { getWorkspaceGraph: ( @@ -82,5 +83,19 @@ export function createSessionBackedApprovalService({ return buildApprovalBundle(graph, documentId); }, + async requestApproval( + _workspaceId: WorkspaceId, + _documentId: DocumentId, + _input: ApprovalRequestDto, + ): Promise { + throw new Error("requestApproval must be provided by the runtime approval service."); + }, + async decideApproval( + _workspaceId: WorkspaceId, + _approvalId: ApprovalId, + _input: ApprovalDecisionDto, + ): Promise { + throw new Error("decideApproval must be provided by the runtime approval service."); + }, }; } diff --git a/apps/desktop/src/services/tauriHarnessDocsServices.ts b/apps/desktop/src/services/tauriHarnessDocsServices.ts index bbe7ee4..0bf0b6c 100644 --- a/apps/desktop/src/services/tauriHarnessDocsServices.ts +++ b/apps/desktop/src/services/tauriHarnessDocsServices.ts @@ -27,6 +27,7 @@ import type { WorkspaceSessionService, WorkspaceSessionSnapshot, } from "./contracts"; +import { createRpcApprovalService } from "./rpcApprovalService"; import { createRpcPublishingService } from "./rpcPublishing"; import { createSessionBackedApprovalService } from "./sessionBackedApprovalService"; import { createSessionBackedPublishingService } from "./sessionBackedPublishingService"; @@ -467,9 +468,13 @@ export function createTauriHarnessDocsServices( listWorkspaceGraphsForUser: sessionReaders.listWorkspaceGraphsForUser, getCurrentSessionUser: sessionReaders.getCurrentSessionUser, }); - const approvals = createSessionBackedApprovalService({ + const sessionBackedApprovals = createSessionBackedApprovalService({ getWorkspaceGraph: sessionReaders.getWorkspaceGraph, }); + const approvals = createRpcApprovalService({ + fallbackService: sessionBackedApprovals, + getSessionToken: () => readAppSessionToken(desktopInfrastructure.storage), + }); const publishing = createTauriPublishingService(desktopInfrastructure, { getWorkspaceGraph: sessionReaders.getWorkspaceGraph, }); diff --git a/apps/desktop/src/types/contracts.ts b/apps/desktop/src/types/contracts.ts index 8e7b600..15a77a6 100644 --- a/apps/desktop/src/types/contracts.ts +++ b/apps/desktop/src/types/contracts.ts @@ -6,9 +6,12 @@ export type { AIDraftSuggestion, AIDraftSuggestionKind, AIDraftSuggestionLifecycleMetadata, + ApprovalDecisionDto, ApprovalAuthority as DocumentApprovalAuthority, ApprovalCandidateSource, ApprovalDecision, + ApprovalMutationEnvelopeDto, + ApprovalRequestDto, AuthoringContext, AuthoringIntent, CommentAnchorKind,