Skip to content
Merged
42 changes: 37 additions & 5 deletions apps/azure/src/components/ProjectsTabView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import React from 'react';
import type { ProcessHub, ControlRecord, ControlHandoff } from '@variscout/core';
import type {
ProcessHub,
ControlRecord,
ControlHandoff,
ProcessParticipantRef,
} from '@variscout/core';
import type { ImprovementProject } from '@variscout/core/improvementProject';
import type { HubAction } from '@variscout/core/actions';
import type { ProjectMember } from '@variscout/core/projectMembership';
Expand Down Expand Up @@ -35,6 +40,23 @@ function liveProjects(hub: ProcessHub | undefined): ImprovementProject[] {
return p && p.deletedAt === null ? [p] : [];
}

/**
* Resolve the acting user as the sign-off approver. Sign-off is decoupled from
* the process owner (IM-7 Task 3): the approver is whoever is acting now,
* resolved from project membership by the signed-in user id, with a generic
* fallback when the user is unknown (no auth context in tests).
*/
function actingApprover(
project: ImprovementProject,
currentUserId: string | undefined
): ProcessParticipantRef {
const me = project.metadata.members?.find(m => m.userId === currentUserId);
if (me) {
return { userId: me.userId, displayName: me.displayName };
}
return { userId: currentUserId, displayName: 'Reviewer' };
}

function mergeProjectPatch(
project: ImprovementProject,
patch: Extract<HubAction, { kind: 'IMPROVEMENT_PROJECT_UPDATE' }>['patch'],
Expand Down Expand Up @@ -152,9 +174,18 @@ const ProjectsTabView: React.FC<ProjectsTabViewProps> = ({
actions={approachInputs?.actions}
now={now}
currentUserId={currentUserId}
onMembersChange={(members: ProjectMember[]) =>
applyProjectPatch(selected, { metadata: { ...selected.metadata, members } })
}
onMembersChange={(members: ProjectMember[]) => {
const prevCount = selected.metadata.members?.length ?? 0;
// Set the durable collaboration marker ONCE, when the roster first
// grows beyond its solo creator (first invite). Never re-stamped and
// never cleared on removal — see ImprovementProject.collaboratedAt.
const markFirstInvite =
members.length > prevCount && !selected.collaboratedAt ? { collaboratedAt: now } : {};
applyProjectPatch(selected, {
metadata: { ...selected.metadata, members },
...markFirstInvite,
});
}}
onRequestSignoff={() =>
applyProjectPatch(selected, {
signoff: {
Expand All @@ -172,7 +203,8 @@ const ProjectsTabView: React.FC<ProjectsTabViewProps> = ({
...(selected.signoff ?? {}),
requestedAt: selected.signoff?.requestedAt ?? Date.now(),
approvedAt: Date.now(),
approvedBy: activeHub.processOwner ?? { displayName: 'Process owner' },
// Acting user, not the process owner (sign-off decoupled, IM-7 Task 3).
approvedBy: actingApprover(selected, currentUserId),
},
})
}
Expand Down
189 changes: 188 additions & 1 deletion apps/azure/src/components/__tests__/ProjectsTabView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ describe('ProjectsTabView', () => {
const hub: ProcessHub = {
...baseHub,
processOwner: { displayName: 'Pat Process', upn: 'pat@example.com' },
improvementProject: makeIP(),
// collaboratedAt makes the sign-off section visible (collaboration affordance).
improvementProject: makeIP({ collaboratedAt: 1_700_000_000_000 }),
};

render(
Expand All @@ -107,6 +108,55 @@ describe('ProjectsTabView', () => {
);
});

it('approves with the acting user (not the process owner) and works with no process owner set', () => {
const onProjectPatch = vi.fn();
const hub: ProcessHub = {
...baseHub,
// No processOwner on the hub — sign-off must still be approvable.
improvementProject: makeIP({
collaboratedAt: 1_700_000_000_000,
signoff: { requestedAt: 1_700_000_100_000 },
metadata: {
title: 'Approve without owner',
members: [
{
id: 'pm-lead',
createdAt: 0,
deletedAt: null,
userId: 'analyst@contoso.com',
displayName: 'Analyst Lead',
role: 'lead',
invitedAt: 0,
},
],
},
}),
};

render(
<ProjectsTabView
activeHub={hub}
selectedProjectId="ip-1"
onSelectProject={() => {}}
onProjectPatch={onProjectPatch}
currentUserId="analyst@contoso.com"
/>
);

fireEvent.click(screen.getByRole('button', { name: 'Approve' }));

// approvedBy is the acting user (resolved from membership), never the process owner.
expect(onProjectPatch).toHaveBeenCalledWith(
'ip-1',
expect.objectContaining({
signoff: expect.objectContaining({
approvedAt: expect.any(Number),
approvedBy: expect.objectContaining({ displayName: 'Analyst Lead' }),
}),
})
);
});

it('threads currentUserId into IPDetailPage — charter team section is visible', () => {
// Use 'draft' status so charter is the default active stage (deriveStageState returns
// charter: 'current' for drafts, approach: 'current' for active which shifts the default).
Expand Down Expand Up @@ -176,4 +226,141 @@ describe('ProjectsTabView', () => {
])
);
});

it('sets collaboratedAt once on the first invite (roster grows from solo)', () => {
const onProjectPatch = vi.fn();
const hub: ProcessHub = {
...baseHub,
improvementProject: makeIP({ status: 'draft', metadata: { title: 'First invite' } }),
};

render(
<ProjectsTabView
activeHub={hub}
selectedProjectId="ip-1"
onSelectProject={() => {}}
onProjectPatch={onProjectPatch}
currentUserId="analyst@contoso.com"
/>
);

fireEvent.click(screen.getByRole('button', { name: /invite team/i }));
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'lead@contoso.com' },
});
fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'lead' } });
fireEvent.click(screen.getByRole('button', { name: /^invite$/i }));

expect(onProjectPatch).toHaveBeenCalledWith(
'ip-1',
expect.objectContaining({ collaboratedAt: expect.any(Number) })
);
expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toEqual(
expect.any(Number)
);
});

it('does not re-stamp collaboratedAt on a second invite (idempotent)', () => {
const onProjectPatch = vi.fn();
const existingMarker = 1_700_000_000_000;
const hub: ProcessHub = {
...baseHub,
improvementProject: makeIP({
status: 'draft',
metadata: {
title: 'Already collaborative',
members: [
{
id: 'pm-lead',
createdAt: 0,
deletedAt: null,
userId: 'analyst@contoso.com',
displayName: 'Analyst Lead',
role: 'lead',
invitedAt: 0,
},
],
},
collaboratedAt: existingMarker,
}),
};

render(
<ProjectsTabView
activeHub={hub}
selectedProjectId="ip-1"
onSelectProject={() => {}}
onProjectPatch={onProjectPatch}
currentUserId="analyst@contoso.com"
/>
);

fireEvent.click(screen.getByRole('button', { name: /invite team/i }));
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'member@contoso.com' },
});
fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'member' } });
fireEvent.click(screen.getByRole('button', { name: /^invite$/i }));

// The second invite patches members but must NOT carry a collaboratedAt key
// (the marker is set-once; re-stamping would move the durable timestamp).
const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {};
expect(lastPatch).not.toHaveProperty('collaboratedAt');
expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe(
existingMarker
);
});

it('does not clear collaboratedAt when a member is removed (durable marker)', () => {
const onProjectPatch = vi.fn();
const existingMarker = 1_700_000_000_000;
const hub: ProcessHub = {
...baseHub,
improvementProject: makeIP({
status: 'draft',
metadata: {
title: 'Removal keeps marker',
members: [
{
id: 'pm-lead',
createdAt: 0,
deletedAt: null,
userId: 'analyst@contoso.com',
displayName: 'Analyst Lead',
role: 'lead',
invitedAt: 0,
},
{
id: 'pm-member',
createdAt: 0,
deletedAt: null,
userId: 'member@contoso.com',
displayName: 'Member',
role: 'member',
invitedAt: 0,
},
],
},
collaboratedAt: existingMarker,
}),
};

render(
<ProjectsTabView
activeHub={hub}
selectedProjectId="ip-1"
onSelectProject={() => {}}
onProjectPatch={onProjectPatch}
currentUserId="analyst@contoso.com"
/>
);

fireEvent.click(screen.getByRole('button', { name: 'Remove Member' }));

const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {};
expect(lastPatch).not.toHaveProperty('collaboratedAt');
expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe(
existingMarker
);
});
});
4 changes: 3 additions & 1 deletion apps/azure/src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,9 @@ export const Editor: React.FC<EditorProps> = ({
const projectsClosureInputs = projectsControlHandoff
? {
controlPlanDocumented: false,
trainingDelivered: Boolean(projectsControlHandoff.signoff?.approvedBy),
// Re-pointed from the deleted handoff.signoff to the handoff lifecycle
// (IM-7 §11 #6): "operational" is the fully-handed-off milestone.
trainingDelivered: projectsControlHandoff.status === 'operational',
cadenceAssigned: Boolean(projectsControlRecord?.cadence),
processOwnerAcknowledged: projectsControlHandoff.status !== 'pending',
trainingRef: projectsControlHandoff.referenceUri,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ function makeHandoff(
}

describe('applyAction (Azure) — control handoffs', () => {
it('creates, updates, acknowledges, signs off, marks operational, and archives handoffs', async () => {
it('creates, updates, acknowledges, marks operational, and archives handoffs', async () => {
await db.processHubs.put(makeHub('hub-handoff'));

await applyAction({
Expand All @@ -209,11 +209,6 @@ describe('applyAction (Azure) — control handoffs', () => {
acknowledgedBy: { displayName: 'Process owner' },
notes: 'Accepted',
});
await applyAction({
kind: 'CONTROL_HANDOFF_SIGNOFF',
handoffId: 'handoff-1',
signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } },
});
await applyAction({
kind: 'CONTROL_HANDOFF_MARK_OPERATIONAL',
handoffId: 'handoff-1',
Expand All @@ -232,7 +227,6 @@ describe('applyAction (Azure) — control handoffs', () => {
acknowledgedBy: { displayName: 'Process owner' },
notes: 'Accepted',
},
signoff: { approvedAt: NOW + 2, approvedBy: { displayName: 'Sponsor' } },
});
expect(stored?.deletedAt).toEqual(expect.any(Number));
});
Expand Down
12 changes: 0 additions & 12 deletions apps/azure/src/persistence/applyAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,18 +449,6 @@ export async function applyAction(action: HubAction): Promise<void> {
return;
}

case 'CONTROL_HANDOFF_SIGNOFF': {
const existing = await db.controlHandoffs.get(action.handoffId);
if (!existing) return;
const operationalAt = existing.operationalAt ?? action.signoff.approvedAt ?? Date.now();
await db.controlHandoffs.update(action.handoffId, {
status: 'operational',
operationalAt,
signoff: { ...(existing.signoff ?? {}), ...action.signoff },
});
return;
}

// -------------------------------------------------------------------------
// Session-only — Azure has no dedicated Dexie table today; F3 normalizes.
// -------------------------------------------------------------------------
Expand Down
9 changes: 3 additions & 6 deletions apps/pwa/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,9 @@ function AppMain() {
const projectsClosureInputs = projectsControlHandoff
? {
controlPlanDocumented: false,
trainingDelivered: Boolean(projectsControlHandoff.signoff?.approvedBy),
// Re-pointed from the deleted handoff.signoff to the handoff lifecycle
// (IM-7 §11 #6): "operational" is the fully-handed-off milestone.
trainingDelivered: projectsControlHandoff.status === 'operational',
cadenceAssigned: Boolean(projectsControlRecord?.cadence),
processOwnerAcknowledged: projectsControlHandoff.status !== 'pending',
trainingRef: projectsControlHandoff.referenceUri,
Expand Down Expand Up @@ -1307,11 +1309,6 @@ function AppMain() {
);
});
}}
onNudgeSignoff={projectId => {
console.info(
`[projects] Nudge signoff for ${projectId} — EngagementEvent webhook boundary`
);
}}
onStartNewProject={panels.showCharter}
/>
) : panels.activeView === 'improvement' ? (
Expand Down
Loading