Add company name to invite summaries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d7d01e9819
commit
5e65bb2b92
4 changed files with 116 additions and 13 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { companies, invites } from "@paperclipai/db";
|
||||||
import { accessRoutes } from "../routes/access.js";
|
import { accessRoutes } from "../routes/access.js";
|
||||||
import { errorHandler } from "../middleware/index.js";
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
|
||||||
|
|
@ -51,19 +52,35 @@ function createDbStub() {
|
||||||
inviteType: "company_join",
|
inviteType: "company_join",
|
||||||
allowedJoinTypes: "agent",
|
allowedJoinTypes: "agent",
|
||||||
defaultsPayload: null,
|
defaultsPayload: null,
|
||||||
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
|
expiresAt: new Date("2099-03-07T00:10:00.000Z"),
|
||||||
invitedByUserId: null,
|
invitedByUserId: null,
|
||||||
tokenHash: "hash",
|
tokenHash: "hash",
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
acceptedAt: null,
|
acceptedAt: null,
|
||||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
createdAt: new Date("2099-03-07T00:00:00.000Z"),
|
||||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
updatedAt: new Date("2099-03-07T00:00:00.000Z"),
|
||||||
};
|
};
|
||||||
const returning = vi.fn().mockResolvedValue([createdInvite]);
|
const returning = vi.fn().mockResolvedValue([createdInvite]);
|
||||||
const values = vi.fn().mockReturnValue({ returning });
|
const values = vi.fn().mockReturnValue({ returning });
|
||||||
const insert = vi.fn().mockReturnValue({ values });
|
const insert = vi.fn().mockReturnValue({ values });
|
||||||
|
const select = vi.fn(() => ({
|
||||||
|
from(table: unknown) {
|
||||||
|
return {
|
||||||
|
where: vi.fn().mockImplementation(() => {
|
||||||
|
if (table === invites) {
|
||||||
|
return Promise.resolve([createdInvite]);
|
||||||
|
}
|
||||||
|
if (table === companies) {
|
||||||
|
return Promise.resolve([{ name: "Acme AI" }]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
insert,
|
insert,
|
||||||
|
select,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,9 +160,30 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||||
expect(typeof res.body.token).toBe("string");
|
expect(typeof res.body.token).toBe("string");
|
||||||
|
expect(res.body.companyName).toBe("Acme AI");
|
||||||
expect(res.body.onboardingTextPath).toContain("/api/invites/");
|
expect(res.body.onboardingTextPath).toContain("/api/invites/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes companyName in invite summary responses", async () => {
|
||||||
|
const db = createDbStub();
|
||||||
|
const app = createApp(
|
||||||
|
{
|
||||||
|
type: "board",
|
||||||
|
userId: "user-1",
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
},
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app).get("/api/invites/pcp_invite_test");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.companyId).toBe("company-1");
|
||||||
|
expect(res.body.companyName).toBe("Acme AI");
|
||||||
|
});
|
||||||
|
|
||||||
it("allows board callers with invite permission", async () => {
|
it("allows board callers with invite permission", async () => {
|
||||||
const db = createDbStub();
|
const db = createDbStub();
|
||||||
mockAccessService.canUser.mockResolvedValue(true);
|
mockAccessService.canUser.mockResolvedValue(true);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
agentApiKeys,
|
agentApiKeys,
|
||||||
authUsers,
|
authUsers,
|
||||||
|
companies,
|
||||||
invites,
|
invites,
|
||||||
joinRequests
|
joinRequests
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
|
|
@ -856,7 +857,8 @@ export function normalizeAgentDefaultsForJoin(input: {
|
||||||
function toInviteSummaryResponse(
|
function toInviteSummaryResponse(
|
||||||
req: Request,
|
req: Request,
|
||||||
token: string,
|
token: string,
|
||||||
invite: typeof invites.$inferSelect
|
invite: typeof invites.$inferSelect,
|
||||||
|
companyName: string | null = null
|
||||||
) {
|
) {
|
||||||
const baseUrl = requestBaseUrl(req);
|
const baseUrl = requestBaseUrl(req);
|
||||||
const onboardingPath = `/api/invites/${token}/onboarding`;
|
const onboardingPath = `/api/invites/${token}/onboarding`;
|
||||||
|
|
@ -865,6 +867,7 @@ function toInviteSummaryResponse(
|
||||||
return {
|
return {
|
||||||
id: invite.id,
|
id: invite.id,
|
||||||
companyId: invite.companyId,
|
companyId: invite.companyId,
|
||||||
|
companyName,
|
||||||
inviteType: invite.inviteType,
|
inviteType: invite.inviteType,
|
||||||
allowedJoinTypes: invite.allowedJoinTypes,
|
allowedJoinTypes: invite.allowedJoinTypes,
|
||||||
expiresAt: invite.expiresAt,
|
expiresAt: invite.expiresAt,
|
||||||
|
|
@ -993,6 +996,7 @@ function buildInviteOnboardingManifest(
|
||||||
token: string,
|
token: string,
|
||||||
invite: typeof invites.$inferSelect,
|
invite: typeof invites.$inferSelect,
|
||||||
opts: {
|
opts: {
|
||||||
|
companyName?: string | null;
|
||||||
deploymentMode: DeploymentMode;
|
deploymentMode: DeploymentMode;
|
||||||
deploymentExposure: DeploymentExposure;
|
deploymentExposure: DeploymentExposure;
|
||||||
bindHost: string;
|
bindHost: string;
|
||||||
|
|
@ -1024,7 +1028,12 @@ function buildInviteOnboardingManifest(
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invite: toInviteSummaryResponse(req, token, invite),
|
invite: toInviteSummaryResponse(
|
||||||
|
req,
|
||||||
|
token,
|
||||||
|
invite,
|
||||||
|
opts.companyName ?? null
|
||||||
|
),
|
||||||
onboarding: {
|
onboarding: {
|
||||||
instructions:
|
instructions:
|
||||||
"Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).",
|
"Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).",
|
||||||
|
|
@ -1084,6 +1093,7 @@ export function buildInviteOnboardingTextDocument(
|
||||||
token: string,
|
token: string,
|
||||||
invite: typeof invites.$inferSelect,
|
invite: typeof invites.$inferSelect,
|
||||||
opts: {
|
opts: {
|
||||||
|
companyName?: string | null;
|
||||||
deploymentMode: DeploymentMode;
|
deploymentMode: DeploymentMode;
|
||||||
deploymentExposure: DeploymentExposure;
|
deploymentExposure: DeploymentExposure;
|
||||||
bindHost: string;
|
bindHost: string;
|
||||||
|
|
@ -1133,6 +1143,10 @@ export function buildInviteOnboardingTextDocument(
|
||||||
- expiresAt: ${invite.expiresAt.toISOString()}
|
- expiresAt: ${invite.expiresAt.toISOString()}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
if (manifest.invite.companyName) {
|
||||||
|
lines.push(`- companyName: ${manifest.invite.companyName}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (onboarding.inviteMessage) {
|
if (onboarding.inviteMessage) {
|
||||||
appendBlock(`
|
appendBlock(`
|
||||||
## Message from inviter
|
## Message from inviter
|
||||||
|
|
@ -1882,6 +1896,16 @@ export function accessRoutes(
|
||||||
return { token, created, normalizedAgentMessage };
|
return { token, created, normalizedAgentMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getInviteCompanyName(companyId: string | null) {
|
||||||
|
if (!companyId) return null;
|
||||||
|
const company = await db
|
||||||
|
.select({ name: companies.name })
|
||||||
|
.from(companies)
|
||||||
|
.where(eq(companies.id, companyId))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
return company?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
router.get("/skills/available", (_req, res) => {
|
router.get("/skills/available", (_req, res) => {
|
||||||
res.json({ skills: listAvailableSkills() });
|
res.json({ skills: listAvailableSkills() });
|
||||||
});
|
});
|
||||||
|
|
@ -1942,11 +1966,18 @@ export function accessRoutes(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const inviteSummary = toInviteSummaryResponse(req, token, created);
|
const companyName = await getInviteCompanyName(created.companyId);
|
||||||
|
const inviteSummary = toInviteSummaryResponse(
|
||||||
|
req,
|
||||||
|
token,
|
||||||
|
created,
|
||||||
|
companyName
|
||||||
|
);
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
...created,
|
...created,
|
||||||
token,
|
token,
|
||||||
inviteUrl: `/invite/${token}`,
|
inviteUrl: `/invite/${token}`,
|
||||||
|
companyName,
|
||||||
onboardingTextPath: inviteSummary.onboardingTextPath,
|
onboardingTextPath: inviteSummary.onboardingTextPath,
|
||||||
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
||||||
inviteMessage: inviteSummary.inviteMessage
|
inviteMessage: inviteSummary.inviteMessage
|
||||||
|
|
@ -1987,11 +2018,18 @@ export function accessRoutes(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const inviteSummary = toInviteSummaryResponse(req, token, created);
|
const companyName = await getInviteCompanyName(created.companyId);
|
||||||
|
const inviteSummary = toInviteSummaryResponse(
|
||||||
|
req,
|
||||||
|
token,
|
||||||
|
created,
|
||||||
|
companyName
|
||||||
|
);
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
...created,
|
...created,
|
||||||
token,
|
token,
|
||||||
inviteUrl: `/invite/${token}`,
|
inviteUrl: `/invite/${token}`,
|
||||||
|
companyName,
|
||||||
onboardingTextPath: inviteSummary.onboardingTextPath,
|
onboardingTextPath: inviteSummary.onboardingTextPath,
|
||||||
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
||||||
inviteMessage: inviteSummary.inviteMessage
|
inviteMessage: inviteSummary.inviteMessage
|
||||||
|
|
@ -2016,7 +2054,8 @@ export function accessRoutes(
|
||||||
throw notFound("Invite not found");
|
throw notFound("Invite not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(toInviteSummaryResponse(req, token, invite));
|
const companyName = await getInviteCompanyName(invite.companyId);
|
||||||
|
res.json(toInviteSummaryResponse(req, token, invite, companyName));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/invites/:token/onboarding", async (req, res) => {
|
router.get("/invites/:token/onboarding", async (req, res) => {
|
||||||
|
|
@ -2031,7 +2070,11 @@ export function accessRoutes(
|
||||||
throw notFound("Invite not found");
|
throw notFound("Invite not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(buildInviteOnboardingManifest(req, token, invite, opts));
|
const companyName = await getInviteCompanyName(invite.companyId);
|
||||||
|
res.json(buildInviteOnboardingManifest(req, token, invite, {
|
||||||
|
...opts,
|
||||||
|
companyName
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/invites/:token/onboarding.txt", async (req, res) => {
|
router.get("/invites/:token/onboarding.txt", async (req, res) => {
|
||||||
|
|
@ -2046,9 +2089,15 @@ export function accessRoutes(
|
||||||
throw notFound("Invite not found");
|
throw notFound("Invite not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyName = await getInviteCompanyName(invite.companyId);
|
||||||
res
|
res
|
||||||
.type("text/plain; charset=utf-8")
|
.type("text/plain; charset=utf-8")
|
||||||
.send(buildInviteOnboardingTextDocument(req, token, invite, opts));
|
.send(
|
||||||
|
buildInviteOnboardingTextDocument(req, token, invite, {
|
||||||
|
...opts,
|
||||||
|
companyName
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/invites/:token/test-resolution", async (req, res) => {
|
router.get("/invites/:token/test-resolution", async (req, res) => {
|
||||||
|
|
@ -2458,11 +2507,15 @@ export function accessRoutes(
|
||||||
|
|
||||||
const response = toJoinRequestResponse(created);
|
const response = toJoinRequestResponse(created);
|
||||||
if (claimSecret) {
|
if (claimSecret) {
|
||||||
|
const companyName = await getInviteCompanyName(invite.companyId);
|
||||||
const onboardingManifest = buildInviteOnboardingManifest(
|
const onboardingManifest = buildInviteOnboardingManifest(
|
||||||
req,
|
req,
|
||||||
token,
|
token,
|
||||||
invite,
|
invite,
|
||||||
opts
|
{
|
||||||
|
...opts,
|
||||||
|
companyName
|
||||||
|
}
|
||||||
);
|
);
|
||||||
res.status(202).json({
|
res.status(202).json({
|
||||||
...response,
|
...response,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { api } from "./client";
|
||||||
type InviteSummary = {
|
type InviteSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string | null;
|
companyId: string | null;
|
||||||
|
companyName?: string | null;
|
||||||
inviteType: "company_join" | "bootstrap_ceo";
|
inviteType: "company_join" | "bootstrap_ceo";
|
||||||
allowedJoinTypes: "human" | "agent" | "both";
|
allowedJoinTypes: "human" | "agent" | "both";
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
|
@ -87,6 +88,7 @@ type CompanyInviteCreated = {
|
||||||
inviteUrl: string;
|
inviteUrl: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
allowedJoinTypes: "human" | "agent" | "both";
|
allowedJoinTypes: "human" | "agent" | "both";
|
||||||
|
companyName?: string | null;
|
||||||
onboardingTextPath?: string;
|
onboardingTextPath?: string;
|
||||||
onboardingTextUrl?: string;
|
onboardingTextUrl?: string;
|
||||||
inviteMessage?: string | null;
|
inviteMessage?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ export function InviteLandingPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const invite = inviteQuery.data;
|
const invite = inviteQuery.data;
|
||||||
|
const companyName = invite?.companyName?.trim() || null;
|
||||||
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
|
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
|
||||||
const availableJoinTypes = useMemo(() => {
|
const availableJoinTypes = useMemo(() => {
|
||||||
if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
|
if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
|
||||||
|
|
@ -227,9 +228,18 @@ export function InviteLandingPage() {
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<div className="mx-auto max-w-xl py-10">
|
||||||
<div className="rounded-lg border border-border bg-card p-6">
|
<div className="rounded-lg border border-border bg-card p-6">
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-semibold">
|
||||||
{invite.inviteType === "bootstrap_ceo" ? "Bootstrap your Paperclip instance" : "Join this Paperclip company"}
|
{invite.inviteType === "bootstrap_ceo"
|
||||||
|
? "Bootstrap your Paperclip instance"
|
||||||
|
: companyName
|
||||||
|
? `Join ${companyName}`
|
||||||
|
: "Join this Paperclip company"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Invite expires {dateTime(invite.expiresAt)}.</p>
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{invite.inviteType !== "bootstrap_ceo" && companyName
|
||||||
|
? `You were invited to join ${companyName}. `
|
||||||
|
: null}
|
||||||
|
Invite expires {dateTime(invite.expiresAt)}.
|
||||||
|
</p>
|
||||||
|
|
||||||
{invite.inviteType !== "bootstrap_ceo" && (
|
{invite.inviteType !== "bootstrap_ceo" && (
|
||||||
<div className="mt-5 flex gap-2">
|
<div className="mt-5 flex gap-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue