Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion apps/api/src/application/approval/useCases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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;
}

Expand Down
20 changes: 19 additions & 1 deletion apps/desktop/src/domain/approvals.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -32,11 +35,26 @@ export interface DocumentApprovalBundle {
unresolvedApprovals: UnresolvedApprovalSnapshot[];
}

export interface ApprovalMutationResult {
approval: DocumentApproval;
workspaceGraph: WorkspaceGraph;
}

export interface ApprovalService {
getWorkspacePolicy: (workspaceId: WorkspaceId) => Promise<WorkspaceApprovalPolicy>;
listDocumentApprovalBundles: (workspaceId: WorkspaceId) => Promise<DocumentApprovalBundle[]>;
getDocumentApprovalBundle: (
workspaceId: WorkspaceId,
documentId: DocumentId,
) => Promise<DocumentApprovalBundle | null>;
requestApproval: (
workspaceId: WorkspaceId,
documentId: DocumentId,
input: ApprovalRequestDto,
) => Promise<ApprovalMutationResult>;
decideApproval: (
workspaceId: WorkspaceId,
approvalId: ApprovalId,
input: ApprovalDecisionDto,
) => Promise<ApprovalMutationResult>;
}
120 changes: 120 additions & 0 deletions apps/desktop/src/hooks/approvalActionUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
115 changes: 115 additions & 0 deletions apps/desktop/src/hooks/approvalActionUtils.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
Loading
Loading