Skip to content
Open
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
52 changes: 52 additions & 0 deletions front-api/routes/w/[wId]/credits/upgrade-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,56 @@ describe("/api/w/[wId]/credits/upgrade-requests", () => {
expect(response.status).toBe(403);
});
});

describe("GET ?status=resolved (history)", () => {
it("excludes pending requests and includes resolvedBy once resolved", async () => {
const workspace = await creditPricedWorkspace();
const { user: member } = await createMemberRequest(workspace);

const { user: admin } = await createPrivateApiMockRequest({
method: "GET",
role: "admin",
workspace,
});

const pendingBefore = await honoApp.request(
`${upgradeRequestsUrl(workspace.sId)}?status=resolved`
);
expect((await pendingBefore.json()).requests).toHaveLength(0);

const listResponse = await honoApp.request(
upgradeRequestsUrl(workspace.sId)
);
const requestId = (await listResponse.json()).requests[0].sId;

await honoApp.request(
`${upgradeRequestsUrl(workspace.sId)}/${requestId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "denied" }),
}
);

const historyResponse = await honoApp.request(
`${upgradeRequestsUrl(workspace.sId)}?status=resolved`
);
expect(historyResponse.status).toBe(200);
const { requests: history } = await historyResponse.json();
expect(history).toHaveLength(1);
expect(history[0].status).toBe("denied");
expect(history[0].requester.sId).toBe(member.sId);
expect(history[0].resolvedBy).toEqual({
sId: admin.sId,
name: admin.fullName(),
});
expect(history[0].grantedAwuCredits).toBeNull();

// Still absent from the pending list.
const pendingAfter = await honoApp.request(
upgradeRequestsUrl(workspace.sId)
);
expect((await pendingAfter.json()).requests).toHaveLength(0);
});
});
});
14 changes: 13 additions & 1 deletion front-api/routes/w/[wId]/credits/upgrade-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { UpgradeRequestError } from "@app/lib/api/credits/upgrade_requests"
import {
createUpgradeRequest,
listPendingUpgradeRequests,
listResolvedUpgradeRequests,
resolveUpgradeRequest,
} from "@app/lib/api/credits/upgrade_requests";
import type {
Expand Down Expand Up @@ -32,6 +33,12 @@ const ResolveBodySchema = z.object({
status: z.union([z.literal("approved"), z.literal("denied")]),
});

// Omitted/"pending" preserves the existing behavior (the requests queue);
// "resolved" switches to the admin history view.
const ListUpgradeRequestsQuerySchema = z.object({
status: z.union([z.literal("pending"), z.literal("resolved")]).optional(),
});

// TODO(BACK12): make `reason`/`requestedDurationDays` required once the
// client update that always sends them (front's upgrade-request modal) has
// rolled out to all users. Optional for now so the existing client, which
Expand Down Expand Up @@ -95,9 +102,14 @@ const app = workspaceApp();
app.get(
"/",
ensureIsManager(),
validate("query", ListUpgradeRequestsQuerySchema),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BACK14] Keep Swagger documentation in sync with API schema changes

This endpoint's request schema now accepts a new ?status query parameter, and the response shape also gains new fields (resolvedBy, grantedAwuCredits, grantedExpiresAt, expiredAt) via toJSON(). Please verify the @swagger or @ignoreswagger annotation in this file (and swagger_private_schemas.ts if the response type is referenced there) is up to date.

async (ctx): HandlerResult<GetUpgradeRequestsResponseBody> => {
const auth = ctx.get("auth");
const requests = await listPendingUpgradeRequests(auth);
const { status } = ctx.req.valid("query");
const requests =
status === "resolved"
? await listResolvedUpgradeRequests(auth)
: await listPendingUpgradeRequests(auth);
return ctx.json({ requests });
}
);
Expand Down
72 changes: 72 additions & 0 deletions front-api/routes/w/[wId]/members/[uId]/spend_limit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Authenticator } from "@app/lib/auth";
import * as spendLimits from "@app/lib/metronome/alerts/spend_limits";
import * as planType from "@app/lib/metronome/plan_type";
import * as seatTypes from "@app/lib/metronome/seat_types";
import { MembershipResource } from "@app/lib/resources/membership_resource";
import { MembershipUpgradeRequestResource } from "@app/lib/resources/membership_upgrade_request_resource";
import { createPrivateApiMockRequest } from "@app/tests/utils/generic_private_api_tests";
import { MembershipFactory } from "@app/tests/utils/MembershipFactory";
import { UserFactory } from "@app/tests/utils/UserFactory";
Expand Down Expand Up @@ -518,4 +520,74 @@ describe("/api/w/[wId]/members/[uId]/spend_limit", () => {
expect(updatedMembership?.poolCapOverrideExpiresAt).toBeNull();
});
});

describe("PUT with requestId (linked upgrade request)", () => {
it("snapshots the grant onto the request, then closes it out when superseded", async () => {
const workspace = await makeMetronomeWorkspaceWithCustomer();
const targetUser = await UserFactory.basic();
await MembershipFactory.associate(workspace, targetUser, {
role: "user",
});
const auth = await Authenticator.internalAdminForWorkspace(workspace.sId);
const requestAResult =
await MembershipUpgradeRequestResource.createPending(auth, {
user: targetUser,
reason: "backfill",
requestedDurationDays: 7,
});
if (requestAResult.isErr()) {
throw requestAResult.error;
}
const requestA = requestAResult.value;

await createPrivateApiMockRequest({
method: "PUT",
role: "admin",
workspace,
});

const expiresAtA = Date.now() + 7 * 24 * 60 * 60 * 1000;
const putA = await honoApp.request(
spendLimitUrl(workspace.sId, targetUser.sId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "limited",
awuCredits: 2000,
expiresAt: expiresAtA,
requestId: requestA.sId,
}),
}
);
expect(putA.status).toBe(200);

const afterA = await MembershipUpgradeRequestResource.fetchById(
auth,
requestA.sId
);
expect(afterA?.grantedAwuCredits).toBe(2000);
expect(afterA?.grantedExpiresAt?.getTime()).toBe(expiresAtA);
expect(afterA?.expiredAt).toBeNull();

// A second, unrelated save for the same member supersedes request A's
// grant, even though it isn't itself linked to any request.
const putB = await honoApp.request(
spendLimitUrl(workspace.sId, targetUser.sId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "limited", awuCredits: 500 }),
}
);
expect(putB.status).toBe(200);

const afterB = await MembershipUpgradeRequestResource.fetchById(
auth,
requestA.sId
);
expect(afterB?.grantedAwuCredits).toBe(2000);
expect(afterB?.expiredAt).not.toBeNull();
});
});
});
11 changes: 9 additions & 2 deletions front-api/routes/w/[wId]/members/[uId]/spend_limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ import { apiError, type HandlerResult } from "@front-api/middlewares/utils";
import { validate } from "@front-api/middlewares/validator";
import { z } from "zod";

// `requestId`, set when this save resolves a specific upgrade request, is
// not part of `UserSpendLimit` itself — it's stripped out in the handler and
// passed to `setUserSpendLimit` separately so the granted amount/expiry can
// be snapshotted onto that request for the admin history view.
const UpdateUserSpendLimitBodySchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("unlimited") }),
z.object({ kind: z.literal("unlimited"), requestId: z.string().optional() }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BACK14] Keep Swagger documentation in sync with API schema changes

The request body schema now accepts an optional requestId field. Please verify the @swagger or @ignoreswagger annotation in this file (and any shared private schema) reflects this addition.

z.object({
kind: z.literal("limited"),
awuCredits: z
Expand All @@ -30,6 +34,7 @@ const UpdateUserSpendLimitBodySchema = z.discriminatedUnion("kind", [
.max(MAX_USER_SPEND_LIMIT_AWU_CREDITS),
timeframe: z.enum(SPEND_LIMIT_OVERRIDE_TIMEFRAMES).nullable().optional(),
expiresAt: z.number().int().positive().nullable().optional(),
requestId: z.string().optional(),
}),
]);

Expand Down Expand Up @@ -122,12 +127,14 @@ app.put(
}

const { uId } = ctx.req.valid("param");
const { requestId, ...limit } = ctx.req.valid("json");

const auditContext = getAuditLogContext(auth);
const result = await setUserSpendLimit(auth, {
userId: uId,
limit: ctx.req.valid("json"),
limit,
auditContext,
requestId,
});
if (result.isErr()) {
return apiError(ctx, spendLimitErrorToApiError(result.error));
Expand Down
36 changes: 28 additions & 8 deletions front/components/pages/workspace/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { MembersSelectionBanner } from "@app/components/workspace/MembersSelecti
import { MembersUsageTable } from "@app/components/workspace/MembersUsageTable";
import { getSeatIconColorClass } from "@app/components/workspace/seat_styles";
import { TopUpsHistoryTable } from "@app/components/workspace/TopUpsHistoryTable";
import { UpgradeRequestsHistoryTable } from "@app/components/workspace/UpgradeRequestsHistoryTable";
import { UpgradeRequestsTable } from "@app/components/workspace/UpgradeRequestsTable";
import { LockedSection } from "@app/components/workspace/usage/LockedSection";
import { ModelTiersSettingsCard } from "@app/components/workspace/usage/ModelTiersSettingsCard";
Expand Down Expand Up @@ -73,6 +74,7 @@ import {
import {
useResolveUpgradeRequest,
useUpgradeRequests,
useUpgradeRequestsHistory,
} from "@app/lib/swr/upgrade_requests";
import { useUsageSettings } from "@app/lib/swr/usage_settings";
import {
Expand Down Expand Up @@ -250,12 +252,17 @@ export function UsagePage() {
);
const isWorkspaceAdmin = isAdmin(owner);
const modelsPickerEnabled = hasFeature("models_picker") && isWorkspaceAdmin;
const [membersTab, setMembersTab] = useState<"members" | "requests">(
"members"
);
const [membersTab, setMembersTab] = useState<
"members" | "requests" | "history"
>("members");
const { upgradeRequests, isUpgradeRequestsLoading } = useUpgradeRequests({
workspaceId: owner.sId,
});
const { upgradeRequestsHistory, isUpgradeRequestsHistoryLoading } =
useUpgradeRequestsHistory({
workspaceId: owner.sId,
disabled: membersTab !== "history",
});

const filteredUpgradeRequests = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
Expand Down Expand Up @@ -1085,9 +1092,13 @@ export function UsagePage() {
<ButtonsSwitchList
size="xs"
defaultValue="members"
onValueChange={(v: string) =>
setMembersTab(v === "requests" ? "requests" : "members")
}
onValueChange={(v: string) => {
if (v === "requests" || v === "history") {
setMembersTab(v);
} else {
setMembersTab("members");
}
}}
>
<ButtonsSwitch value="members" label="Members" />
<ButtonsSwitch
Expand All @@ -1100,6 +1111,7 @@ export function UsagePage() {
: undefined
}
/>
<ButtonsSwitch value="history" label="History" />
</ButtonsSwitchList>
{membersTab === "members" && (
<div className="flex flex-row items-center gap-2">
Expand All @@ -1116,12 +1128,13 @@ export function UsagePage() {
)}
</div>
<div className="flex flex-col gap-2 pt-2">
{membersTab === "members" ? (
{membersTab === "members" && (
<>
{selectionBanner}
{membersTable}
</>
) : (
)}
{membersTab === "requests" && (
<UpgradeRequestsTable
requests={filteredUpgradeRequests}
isLoading={isUpgradeRequestsLoading}
Expand All @@ -1132,6 +1145,12 @@ export function UsagePage() {
onDeny={handleDenyRequest}
/>
)}
{membersTab === "history" && (
<UpgradeRequestsHistoryTable
requests={upgradeRequestsHistory}
isLoading={isUpgradeRequestsHistoryLoading}
/>
)}
</div>
</div>
</Page.Vertical>
Expand Down Expand Up @@ -1219,6 +1238,7 @@ export function UsagePage() {
upgradeRequests.find((r) => r.sId === pendingApproveRequestId)
?.requestedDurationDays ?? null
}
linkedRequestId={pendingApproveRequestId}
onSavingChange={handleUsagePendingChange}
onSaved={handleApproveOnModalSaved}
/>
Expand Down
5 changes: 5 additions & 0 deletions front/components/workspace/EditSpendLimitModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ interface EditSpendLimitModalProps {
// opened to resolve one. Pre-fills the expiry date; the admin can still
// change it. Null when opened from the members table directly.
requestedDurationDays?: number | null;
// sId of the linked upgrade request, if any — forwarded on save so the
// granted amount/expiry gets recorded on it for the admin history view.
linkedRequestId?: string | null;
onSavingChange?: (memberId: string, isSaving: boolean) => void;
// Fired once the spend limit has been persisted successfully (not on cancel
// or a load error). Used to resolve a linked upgrade request as approved.
Expand All @@ -86,6 +89,7 @@ export function EditSpendLimitModal({
member,
owner,
requestedDurationDays,
linkedRequestId,
onSavingChange,
onSaved,
}: EditSpendLimitModalProps) {
Expand Down Expand Up @@ -254,6 +258,7 @@ export function EditSpendLimitModal({
memberId: displayedMember.sId,
memberName: displayedMember.name,
limit,
requestId: linkedRequestId,
});
if (body) {
onSaved?.();
Expand Down
Loading
Loading