Skip to content

Commit 6381d78

Browse files
feat(admin): support org profile updates (#3416)
1 parent 5d6c9c6 commit 6381d78

10 files changed

Lines changed: 621 additions & 3 deletions

File tree

.agents/skills/clawhub-moderation/SKILL.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ has asked for fuzzy handle resolution or the exact handle is ambiguous.
115115
```text
116116
official
117117
create <handle>
118+
profile update <handle>
118119
remove-member <handle> <member>
119120
delete <handle>
120121
repair-scoped-packages <csv>
@@ -127,6 +128,8 @@ bun run admin -- org official list
127128
bun run admin -- org official add <handle> --reason "<reason>" --yes
128129
bun run admin -- org official remove <handle> --reason "<reason>" --yes
129130
bun run admin -- org create <handle> --display-name "<name>" --member <user-handle> --role owner
131+
bun run admin -- org profile update <handle> --bio "<description>" --reason "<reason>" --yes
132+
bun run admin -- org profile update <handle> --logo-file <path> --reason "<reason>" --yes
130133
bun run admin -- org remove-member <handle> <member-handle>
131134
bun run admin -- org delete <handle> --reason "<reason>" # dry-run
132135
bun run admin -- org delete <handle> --reason "<reason>" --apply
@@ -136,7 +139,8 @@ bun run admin -- org repair-scoped-packages <csv> --apply
136139

137140
`org create` requires `--member`; it must not add the moderator running the
138141
command as an implicit owner. `org delete` only works for empty org publishers
139-
and defaults to dry-run.
142+
and defaults to dry-run. `org profile update` accepts a bio, a PNG/JPEG/WebP
143+
logo under 2 MB, or both, and records the required reason in the audit log.
140144

141145
### Plugin Packages
142146

convex/httpApiV1.handlers.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1697,6 +1697,105 @@ describe("httpApiV1 handlers", () => {
16971697
);
16981698
});
16991699

1700+
it("users/publisher-profile updates an org bio and stores a logo for admin", async () => {
1701+
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
1702+
if (isRateLimitArgs(args)) return okRate();
1703+
return {
1704+
ok: true,
1705+
publisherId: "publishers:heygen",
1706+
handle: "heygen-com",
1707+
bio: "HeyGen is an AI video platform.",
1708+
image: "https://storage.example/heygen-logo",
1709+
bioUpdated: true,
1710+
logoUpdated: true,
1711+
};
1712+
});
1713+
const store = vi.fn(async () => "storage:heygen-logo");
1714+
const remove = vi.fn(async () => {});
1715+
vi.mocked(requireApiTokenUser).mockResolvedValue({
1716+
userId: "users:admin",
1717+
user: { _id: "users:admin", role: "admin" },
1718+
} as never);
1719+
const form = new FormData();
1720+
form.set(
1721+
"payload",
1722+
JSON.stringify({
1723+
handle: "HeyGen-Com",
1724+
bio: "HeyGen is an AI video platform.",
1725+
reason: "Refresh official publisher profile",
1726+
}),
1727+
);
1728+
form.set(
1729+
"logo",
1730+
new File([new Uint8Array([137, 80, 78, 71])], "heygen.png", { type: "image/png" }),
1731+
);
1732+
1733+
const response = await __handlers.usersPostRouterV1Handler(
1734+
makeCtx({
1735+
runQuery: vi.fn(),
1736+
runAction: vi.fn(),
1737+
runMutation,
1738+
storage: { store, delete: remove },
1739+
}),
1740+
new Request("https://example.com/api/v1/users/publisher-profile", {
1741+
method: "POST",
1742+
body: form,
1743+
}),
1744+
);
1745+
if (response.status !== 200) throw new Error(await response.text());
1746+
1747+
expect(store).toHaveBeenCalledOnce();
1748+
expect(remove).not.toHaveBeenCalled();
1749+
expect(runMutation).toHaveBeenCalledWith(
1750+
expect.anything(),
1751+
expect.objectContaining({
1752+
actorUserId: "users:admin",
1753+
handle: "heygen-com",
1754+
bio: "HeyGen is an AI video platform.",
1755+
imageStorageId: "storage:heygen-logo",
1756+
reason: "Refresh official publisher profile",
1757+
}),
1758+
);
1759+
expect(await response.json()).toMatchObject({
1760+
ok: true,
1761+
handle: "heygen-com",
1762+
bioUpdated: true,
1763+
logoUpdated: true,
1764+
});
1765+
});
1766+
1767+
it("users/publisher-profile forbids non-admin api tokens before storing files", async () => {
1768+
const store = vi.fn(async () => "storage:unused");
1769+
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
1770+
if (isRateLimitArgs(args)) return okRate();
1771+
throw new Error(`unexpected mutation ${JSON.stringify(args)}`);
1772+
});
1773+
vi.mocked(requireApiTokenUser).mockResolvedValue({
1774+
userId: "users:member",
1775+
user: { _id: "users:member", role: "user" },
1776+
} as never);
1777+
const form = new FormData();
1778+
form.set(
1779+
"payload",
1780+
JSON.stringify({
1781+
handle: "opik",
1782+
bio: "Opik is an AI observability platform.",
1783+
reason: "Refresh official publisher profile",
1784+
}),
1785+
);
1786+
1787+
const response = await __handlers.usersPostRouterV1Handler(
1788+
makeCtx({ runQuery: vi.fn(), runAction: vi.fn(), runMutation, storage: { store } }),
1789+
new Request("https://example.com/api/v1/users/publisher-profile", {
1790+
method: "POST",
1791+
body: form,
1792+
}),
1793+
);
1794+
1795+
expect(response.status).toBe(403);
1796+
expect(store).not.toHaveBeenCalled();
1797+
});
1798+
17001799
it("users/publisher-recovery plans personal publisher recovery for admin", async () => {
17011800
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
17021801
if (isRateLimitArgs(args)) return okRate();

convex/httpApiV1/usersV1.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const usersV1InternalRefs = internal as unknown as {
2424
removeOrgPublisherMemberInternal: unknown;
2525
removeOfficialPublisherInternal: unknown;
2626
recoverPersonalPublisherInternal: unknown;
27+
updateOrgPublisherProfileInternal: unknown;
2728
};
2829
users: {
2930
getBanAppealContextByGitHubProviderAccountIdInternal: unknown;
@@ -98,12 +99,21 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
9899
action !== "publisher-delete" &&
99100
action !== "publisher-official" &&
100101
action !== "publisher-member" &&
102+
action !== "publisher-profile" &&
101103
action !== "publisher-reclaim" &&
102104
action !== "publisher-recovery"
103105
) {
104106
return text("Not found", 404, rate.headers);
105107
}
106108

109+
if (action === "publisher-profile") {
110+
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
111+
if (!authResult.ok) return authResult.response;
112+
const admin = requireAdminOrResponse(authResult.user, rate.headers);
113+
if (!admin.ok) return admin.response;
114+
return handleAdminUpdatePublisherProfile(ctx, request, authResult.userId, rate.headers);
115+
}
116+
107117
const payloadResult = await parseJsonPayload(request, rate.headers);
108118
if (!payloadResult.ok) return payloadResult.response;
109119
const payload = payloadResult.payload;
@@ -930,6 +940,85 @@ async function handleAdminEnsurePublisher(
930940
}
931941
}
932942

943+
const PUBLISHER_PROFILE_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
944+
const PUBLISHER_PROFILE_IMAGE_CONTENT_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
945+
946+
async function handleAdminUpdatePublisherProfile(
947+
ctx: ActionCtx,
948+
request: Request,
949+
actorUserId: Id<"users">,
950+
headers: HeadersInit,
951+
) {
952+
let form: FormData;
953+
try {
954+
form = await request.formData();
955+
} catch {
956+
return text("Invalid multipart form", 400, headers);
957+
}
958+
const payloadRaw = form.get("payload");
959+
if (typeof payloadRaw !== "string") return text("Missing payload", 400, headers);
960+
let payload: Record<string, unknown>;
961+
try {
962+
const parsed = JSON.parse(payloadRaw) as unknown;
963+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
964+
return text("JSON payload must be an object", 400, headers);
965+
}
966+
payload = parsed as Record<string, unknown>;
967+
} catch {
968+
return text("Invalid JSON payload", 400, headers);
969+
}
970+
971+
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
972+
const reason = typeof payload.reason === "string" ? payload.reason.trim() : "";
973+
const hasBio = Object.prototype.hasOwnProperty.call(payload, "bio");
974+
const bio = typeof payload.bio === "string" ? payload.bio.trim() : undefined;
975+
if (!handle) return text("Missing handle", 400, headers);
976+
if (!reason) return text("Missing reason", 400, headers);
977+
if (reason.length > 500) return text("Reason too long (max 500 chars)", 400, headers);
978+
if (hasBio && typeof payload.bio !== "string") return text("bio must be a string", 400, headers);
979+
980+
const logoParts = form.getAll("logo");
981+
if (logoParts.length > 1) return text("Upload one logo", 400, headers);
982+
const logo = logoParts[0];
983+
if (typeof logo === "string") return text("logo must be a file", 400, headers);
984+
if (!hasBio && !logo) return text("bio or logo required", 400, headers);
985+
if (
986+
logo &&
987+
(logo.size <= 0 ||
988+
logo.size > PUBLISHER_PROFILE_IMAGE_MAX_BYTES ||
989+
!PUBLISHER_PROFILE_IMAGE_CONTENT_TYPES.has(logo.type))
990+
) {
991+
return text("Logo must be a PNG, JPEG, or WebP image smaller than 2 MB", 400, headers);
992+
}
993+
994+
let imageStorageId: Id<"_storage"> | undefined;
995+
try {
996+
if (logo) imageStorageId = await ctx.storage.store(logo);
997+
const result = await runUsersV1MutationRef<{
998+
ok: true;
999+
publisherId: Id<"publishers">;
1000+
handle: string;
1001+
bio: string | null;
1002+
image: string | null;
1003+
bioUpdated: boolean;
1004+
logoUpdated: boolean;
1005+
}>(ctx, usersV1InternalRefs.publishers.updateOrgPublisherProfileInternal, {
1006+
actorUserId,
1007+
handle,
1008+
...(hasBio ? { bio: bio ?? "" } : {}),
1009+
...(imageStorageId ? { imageStorageId } : {}),
1010+
reason,
1011+
});
1012+
return json(result, 200, headers);
1013+
} catch (error) {
1014+
if (imageStorageId) await ctx.storage.delete(imageStorageId);
1015+
const message = error instanceof Error ? error.message : "Publisher profile update failed";
1016+
if (/not found/i.test(message)) return text(message, 404, headers);
1017+
if (/unauthorized|forbidden/i.test(message)) return text("Forbidden", 403, headers);
1018+
return text(message, 400, headers);
1019+
}
1020+
}
1021+
9331022
async function handleBanAppealUnban(
9341023
ctx: ActionCtx,
9351024
request: Request,

0 commit comments

Comments
 (0)