Manage stakeholders implemented

This commit is contained in:
adilallo
2026-05-09 23:07:59 -06:00
parent 534c6c7c0e
commit 9f2141a62d
43 changed files with 2082 additions and 93 deletions
+6 -3
View File
@@ -7,7 +7,10 @@ import {
createFlowStepPathAfterStrippingReviewReturn,
createFlowStepPathWithSyncDraft,
} from "../../app/(app)/create/utils/createFlowPaths";
import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "../../app/(app)/create/utils/flowSteps";
import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
} from "../../app/(app)/create/utils/flowSteps";
describe("createFlowPaths (CR-92 §2)", () => {
it("createFlowStepPath builds segment path", () => {
@@ -26,9 +29,9 @@ describe("createFlowPaths (CR-92 §2)", () => {
);
});
it("createFlowStepPathAfterStrippingReviewReturn drops reviewReturn only", () => {
it("createFlowStepPathAfterStrippingReviewReturn drops reviewReturn and manageStakeholders", () => {
const sp = new URLSearchParams(
`a=1&${CREATE_FLOW_REVIEW_RETURN_QUERY_KEY}=final-review&b=2`,
`a=1&${CREATE_FLOW_REVIEW_RETURN_QUERY_KEY}=final-review&${CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY}=1&b=2`,
);
expect(createFlowStepPathAfterStrippingReviewReturn("final-review", sp)).toBe(
"/create/final-review?a=1&b=2",
+34
View File
@@ -7,6 +7,7 @@ import {
createFlowStateSchema,
publishRuleBodySchema,
putDraftBodySchema,
uniqueStakeholderEmailsForPublish,
} from "../../lib/server/validation/createFlowSchemas";
describe("assertPlainJsonValue", () => {
@@ -175,6 +176,16 @@ describe("createFlowStateSchema", () => {
});
expect(r.success).toBe(false);
});
it("accepts stakeholderEmails on draft payload", () => {
const r = createFlowStateSchema.safeParse({
stakeholderEmails: [" one@example.com "],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.stakeholderEmails).toEqual(["one@example.com"]);
}
});
});
describe("putDraftBodySchema", () => {
@@ -224,4 +235,27 @@ describe("publishRuleBodySchema", () => {
});
expect(r.success).toBe(false);
});
it("normalizes stakeholderEmails", () => {
const r = publishRuleBodySchema.safeParse({
title: "Ok",
document: {},
stakeholderEmails: [" A@Example.COM ", "b@example.com"],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.stakeholderEmails).toEqual(["a@example.com", "b@example.com"]);
}
});
});
describe("uniqueStakeholderEmailsForPublish", () => {
it("dedupes and drops publisher email", () => {
expect(
uniqueStakeholderEmailsForPublish(
["a@b.c", "A@B.C", "x@y.z"],
"a@b.c",
),
).toEqual(["x@y.z"]);
});
});
@@ -80,6 +80,37 @@ describe("useCreateFlowFinalize", () => {
});
});
it("passes stakeholderEmails to publishRule on initial publish", async () => {
vi.mocked(publishRule).mockResolvedValue({
ok: true,
id: "new-rule-id",
title: "Published title",
});
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: {
...emptyState,
stakeholderEmails: ["invitee@example.com"],
},
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
}),
);
await act(async () => {
await result.current.finalize();
});
expect(publishRule).toHaveBeenCalledWith(
expect.objectContaining({
stakeholderEmails: ["invitee@example.com"],
}),
);
});
it("routes to /create/completed without celebrate after PATCH update", async () => {
vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true });
@@ -0,0 +1,100 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const findUniqueMock = vi.fn();
const updateMock = vi.fn();
const upsertMock = vi.fn();
const getSessionUserMock = vi.fn();
const createSessionMock = vi.fn();
const setCookieMock = vi.fn();
vi.mock("../../lib/server/db", () => ({
prisma: {
ruleStakeholder: {
findUnique: (...args: unknown[]) => findUniqueMock(...args),
update: (...args: unknown[]) => updateMock(...args),
},
user: {
upsert: (...args: unknown[]) => upsertMock(...args),
},
},
}));
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
getSessionPepper: () => "pepper",
}));
vi.mock("../../lib/server/hash", () => ({
hashSessionToken: (t: string) => `h-${t}`,
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
createSessionForUser: (...args: unknown[]) => createSessionMock(...args),
setSessionCookie: (...args: unknown[]) => setCookieMock(...args),
}));
import { GET } from "../../app/api/invites/rule-stakeholder/verify/route";
beforeEach(() => {
findUniqueMock.mockReset();
updateMock.mockReset();
upsertMock.mockReset();
getSessionUserMock.mockReset();
createSessionMock.mockReset();
setCookieMock.mockReset();
});
describe("GET /api/invites/rule-stakeholder/verify", () => {
it("redirects to the rule after a valid token", async () => {
getSessionUserMock.mockResolvedValue(null);
findUniqueMock.mockResolvedValue({
id: "st1",
email: "inv@example.com",
ruleId: "rule-1",
inviteExpiresAt: new Date(Date.now() + 60_000),
});
upsertMock.mockResolvedValue({ id: "u1", email: "inv@example.com" });
updateMock.mockResolvedValue({});
createSessionMock.mockResolvedValue({
token: "sess",
expiresAt: new Date(),
});
const res = await GET(
new NextRequest(
`https://x.test/api/invites/rule-stakeholder/verify?token=${"x".repeat(12)}`,
),
);
expect(res.status).toBeGreaterThanOrEqual(300);
expect(res.status).toBeLessThan(400);
expect(res.headers.get("location")).toContain("/rules/rule-1");
expect(setCookieMock).toHaveBeenCalled();
});
it("redirects to login when another user is already signed in", async () => {
getSessionUserMock.mockResolvedValue({
id: "u-other",
email: "other@example.com",
});
findUniqueMock.mockResolvedValue({
id: "st1",
email: "inv@example.com",
ruleId: "rule-1",
inviteExpiresAt: new Date(Date.now() + 60_000),
});
const res = await GET(
new NextRequest(
`https://x.test/api/invites/rule-stakeholder/verify?token=${"y".repeat(12)}`,
),
);
expect(res.headers.get("location")).toContain(
"error=stakeholder_wrong_account",
);
expect(upsertMock).not.toHaveBeenCalled();
});
});
+10 -7
View File
@@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const isDatabaseConfiguredMock = vi.fn();
const listForUserMock = vi.fn();
const listProfileMock = vi.fn();
const getSessionUserMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
@@ -10,7 +10,7 @@ vi.mock("../../lib/server/env", () => ({
}));
vi.mock("../../lib/server/publishedRules", () => ({
listPublishedRulesForUser: (...args: unknown[]) => listForUserMock(...args),
listProfileRulesForUser: (...args: unknown[]) => listProfileMock(...args),
}));
vi.mock("../../lib/server/session", () => ({
@@ -21,7 +21,7 @@ import { GET } from "../../app/api/rules/me/route";
beforeEach(() => {
isDatabaseConfiguredMock.mockReset();
listForUserMock.mockReset();
listProfileMock.mockReset();
getSessionUserMock.mockReset();
});
@@ -44,7 +44,7 @@ describe("GET /api/rules/me", () => {
undefined,
);
expect(res.status).toBe(401);
expect(listForUserMock).not.toHaveBeenCalled();
expect(listProfileMock).not.toHaveBeenCalled();
});
it("returns 200 with { rules } for the session user", async () => {
@@ -59,17 +59,20 @@ describe("GET /api/rules/me", () => {
updatedAt: new Date("2026-01-02T00:00:00Z"),
},
];
listForUserMock.mockResolvedValueOnce(rows);
listProfileMock.mockResolvedValueOnce(
rows.map((r) => ({ ...r, role: "owner" as const })),
);
const res = await GET(
new NextRequest("https://x.test/api/rules/me?limit=10"),
undefined,
);
expect(res.status).toBe(200);
expect(listForUserMock).toHaveBeenCalledWith("user-1", 10);
expect(listProfileMock).toHaveBeenCalledWith("user-1", 10);
const body = (await res.json()) as {
rules: Array<{ id: string; title: string }>;
rules: Array<{ id: string; title: string; role: string }>;
};
expect(body.rules).toHaveLength(1);
expect(body.rules[0].id).toBe("r1");
expect(body.rules[0].role).toBe("owner");
});
});
+174
View File
@@ -0,0 +1,174 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const getSessionUserMock = vi.fn();
const transactionMock = vi.fn();
const publishedRuleCreateMock = vi.fn();
const publishedRuleDeleteMock = vi.fn();
const sendInviteMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
getSessionPepper: () => "test-pepper",
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
}));
vi.mock("../../lib/server/rateLimit", () => ({
rateLimitKey: () => ({ ok: true as const }),
}));
vi.mock("../../lib/server/mail", () => ({
sendRuleStakeholderInviteEmail: (...args: unknown[]) =>
sendInviteMock(...args),
}));
vi.mock("../../lib/server/hash", () => ({
newSessionToken: () => "x".repeat(32),
hashSessionToken: (t: string) => `hashed-${t}`,
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
$transaction: (fn: (tx: unknown) => Promise<unknown>) =>
transactionMock(fn),
publishedRule: {
create: (...args: unknown[]) => publishedRuleCreateMock(...args),
delete: (...args: unknown[]) => publishedRuleDeleteMock(...args),
},
ruleStakeholder: {
create: vi.fn().mockResolvedValue({}),
},
},
}));
import { POST } from "../../app/api/rules/route";
beforeEach(() => {
getSessionUserMock.mockReset();
transactionMock.mockReset();
publishedRuleCreateMock.mockReset();
publishedRuleDeleteMock.mockReset();
sendInviteMock.mockReset();
getSessionUserMock.mockResolvedValue({
id: "user-1",
email: "owner@example.com",
});
});
describe("POST /api/rules", () => {
it("creates rule without transaction when there are no stakeholder emails", async () => {
publishedRuleCreateMock.mockResolvedValueOnce({
id: "rule-solo",
title: "T",
summary: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
});
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
body: JSON.stringify({
title: "T",
summary: null,
document: {},
}),
}),
undefined,
);
expect(res.status).toBe(200);
expect(transactionMock).not.toHaveBeenCalled();
expect(publishedRuleCreateMock).toHaveBeenCalledTimes(1);
expect(sendInviteMock).not.toHaveBeenCalled();
});
it("uses a transaction and sends stakeholder invites", async () => {
const created = {
id: "rule-new",
title: "Published title",
summary: null,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
transactionMock.mockImplementation(
async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise<unknown>) => {
const tx = {
publishedRule: {
create: vi.fn().mockResolvedValue(created),
},
ruleStakeholder: {
create: vi.fn().mockResolvedValue({}),
},
};
return fn(tx);
},
);
sendInviteMock.mockResolvedValue(undefined);
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
body: JSON.stringify({
title: "Published title",
summary: null,
document: {},
stakeholderEmails: ["stakeholder@example.com"],
}),
}),
undefined,
);
expect(res.status).toBe(200);
expect(transactionMock).toHaveBeenCalledTimes(1);
expect(sendInviteMock).toHaveBeenCalledTimes(1);
expect(sendInviteMock.mock.calls[0][0]).toBe("stakeholder@example.com");
expect(String(sendInviteMock.mock.calls[0][1])).toContain(
"/api/invites/rule-stakeholder/verify?token=",
);
expect(publishedRuleCreateMock).not.toHaveBeenCalled();
});
it("rolls back publish when mail fails", async () => {
const created = {
id: "rule-new",
title: "Published title",
summary: null,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
transactionMock.mockImplementation(
async (fn: (tx: { publishedRule: { create: typeof vi.fn }; ruleStakeholder: { create: typeof vi.fn } }) => Promise<unknown>) => {
const tx = {
publishedRule: {
create: vi.fn().mockResolvedValue(created),
},
ruleStakeholder: {
create: vi.fn().mockResolvedValue({}),
},
};
return fn(tx);
},
);
sendInviteMock.mockRejectedValueOnce(new Error("smtp down"));
publishedRuleDeleteMock.mockResolvedValueOnce({});
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
body: JSON.stringify({
title: "Published title",
summary: null,
document: {},
stakeholderEmails: ["stakeholder@example.com"],
}),
}),
undefined,
);
expect(res.status).toBe(502);
expect(publishedRuleDeleteMock).toHaveBeenCalledWith({
where: { id: "rule-new" },
});
});
});
@@ -0,0 +1,79 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const findManyStakeholdersMock = vi.fn();
const findFirstRuleMock = vi.fn();
const getSessionUserMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
publishedRule: {
findFirst: (...args: unknown[]) => findFirstRuleMock(...args),
},
ruleStakeholder: {
findMany: (...args: unknown[]) => findManyStakeholdersMock(...args),
},
},
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
}));
import { GET } from "../../app/api/rules/[id]/stakeholders/route";
beforeEach(() => {
findManyStakeholdersMock.mockReset();
findFirstRuleMock.mockReset();
getSessionUserMock.mockReset();
getSessionUserMock.mockResolvedValue({ id: "owner-1", email: "o@example.com" });
});
describe("GET /api/rules/[id]/stakeholders", () => {
it("returns 401 when unauthenticated", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
const res = await GET(
new NextRequest("https://x.test/api/rules/r1/stakeholders"),
{ params: Promise.resolve({ id: "r1" }) },
);
expect(res.status).toBe(401);
});
it("returns stakeholders for the rule owner", async () => {
findFirstRuleMock.mockResolvedValueOnce({
id: "r1",
title: "My rule",
});
findManyStakeholdersMock.mockResolvedValueOnce([
{
id: "s1",
email: "a@b.c",
invitedAt: new Date("2026-01-01T00:00:00Z"),
acceptedAt: null,
inviteTokenHash: "hash",
},
{
id: "s2",
email: "x@y.z",
invitedAt: new Date("2026-01-02T00:00:00Z"),
acceptedAt: new Date("2026-01-03T00:00:00Z"),
inviteTokenHash: null,
},
]);
const res = await GET(
new NextRequest("https://x.test/api/rules/r1/stakeholders"),
{ params: Promise.resolve({ id: "r1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
stakeholders: Array<{ status: string; email: string }>;
};
expect(body.stakeholders).toHaveLength(2);
expect(body.stakeholders[0].status).toBe("pending");
expect(body.stakeholders[1].status).toBe("accepted");
});
});