Implement skills store API foundation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-11 16:18:33 +00:00
1 parent 69a368ed51
commit db599edadf
23 files changed
+2145 -53

No files matched your search

@@ -12,6 +12,19 @@ const mockAccessService = vi.hoisted(() => ({
}));
const mockCompanySkillService = vi.hoisted(() => ({
list: vi.fn(),
categoryCounts: vi.fn(),
detail: vi.fn(),
listVersions: vi.fn(),
getVersion: vi.fn(),
createVersion: vi.fn(),
starSkill: vi.fn(),
unstarSkill: vi.fn(),
forkSkill: vi.fn(),
listComments: vi.fn(),
createComment: vi.fn(),
updateComment: vi.fn(),
deleteComment: vi.fn(),
importFromSource: vi.fn(),
installFromCatalog: vi.fn(),
deleteSkill: vi.fn(),
@@ -102,6 +115,101 @@ describe("company skill mutation permissions", () => {
imported: [],
warnings: [],
});
mockCompanySkillService.list.mockResolvedValue([]);
mockCompanySkillService.categoryCounts.mockResolvedValue([]);
mockCompanySkillService.detail.mockResolvedValue(null);
mockCompanySkillService.listVersions.mockResolvedValue([]);
mockCompanySkillService.getVersion.mockResolvedValue(null);
mockCompanySkillService.createVersion.mockResolvedValue({
id: "version-1",
companyId: "company-1",
companySkillId: "skill-1",
revisionNumber: 1,
label: "v1",
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Skill" }],
authorAgentId: null,
authorUserId: "board",
createdAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.starSkill.mockResolvedValue({
skillId: "skill-1",
starred: true,
starCount: 1,
});
mockCompanySkillService.unstarSkill.mockResolvedValue({
skillId: "skill-1",
starred: false,
starCount: 0,
});
mockCompanySkillService.forkSkill.mockResolvedValue({
id: "skill-fork",
companyId: "company-1",
key: "company/company-1/review-fork",
slug: "review-fork",
name: "Review Fork",
description: null,
markdown: "# Review",
sourceType: "local_path",
sourceLocator: "/tmp/review-fork",
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
iconUrl: null,
color: null,
tagline: null,
authorName: null,
homepageUrl: null,
categories: [],
sharingScope: "company",
publicShareToken: null,
forkedFromSkillId: "skill-1",
forkedFromCompanyId: "company-1",
starCount: 0,
installCount: 1,
forkCount: 0,
currentVersionId: null,
metadata: null,
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.listComments.mockResolvedValue([]);
mockCompanySkillService.createComment.mockResolvedValue({
id: "comment-1",
companyId: "company-1",
companySkillId: "skill-1",
parentCommentId: null,
authorAgentId: null,
authorUserId: "board",
body: "Looks good",
deletedAt: null,
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.updateComment.mockResolvedValue({
id: "comment-1",
companyId: "company-1",
companySkillId: "skill-1",
parentCommentId: null,
authorAgentId: null,
authorUserId: "board",
body: "Updated",
deletedAt: null,
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.deleteComment.mockResolvedValue({
id: "comment-1",
companyId: "company-1",
companySkillId: "skill-1",
parentCommentId: null,
authorAgentId: null,
authorUserId: "board",
body: "Updated",
deletedAt: new Date("2026-05-26T00:01:00.000Z"),
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:01:00.000Z"),
});
mockCompanySkillService.installFromCatalog.mockResolvedValue({
action: "created",
skill: {
@@ -484,6 +592,77 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled();
});
it("passes store list filters and category count requests to the service", async () => {
const app = await createApp({ type: "board", source: "local_implicit" });
await request(app)
.get("/api/companies/company-1/skills?sort=stars&categories[]=memory&category=git&scope=company&q=review")
.expect(200);
expect(mockCompanySkillService.list).toHaveBeenCalledWith("company-1", {
q: "review",
sort: "stars",
categories: ["git", "memory"],
scope: "company",
});
await request(app).get("/api/companies/company-1/skills/categories").expect(200);
expect(mockCompanySkillService.categoryCounts).toHaveBeenCalledWith("company-1");
});
it("creates skill versions and logs the mutation", async () => {
const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" });
await request(app)
.post("/api/companies/company-1/skills/skill-1/versions")
.send({ label: "v1" })
.expect(201);
expect(mockCompanySkillService.createVersion).toHaveBeenCalledWith("company-1", "skill-1", { label: "v1" }, {
type: "user",
userId: "user-1",
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_version_created",
entityType: "company_skill_version",
entityId: "version-1",
}));
});
it("stars, forks, and comments on skills through company-scoped endpoints", async () => {
const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" });
await request(app).post("/api/companies/company-1/skills/skill-1/star").send({}).expect(200);
expect(mockCompanySkillService.starSkill).toHaveBeenCalledWith("company-1", "skill-1", {
type: "user",
userId: "user-1",
});
await request(app).post("/api/companies/company-1/skills/skill-1/fork").send({ slug: "review-fork" }).expect(201);
expect(mockCompanySkillService.forkSkill).toHaveBeenCalledWith("company-1", "skill-1", { slug: "review-fork" }, {
type: "user",
userId: "user-1",
});
await request(app).post("/api/companies/company-1/skills/skill-1/comments").send({ body: "Looks good" }).expect(201);
expect(mockCompanySkillService.createComment).toHaveBeenCalledWith("company-1", "skill-1", { body: "Looks good" }, {
type: "user",
userId: "user-1",
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_starred",
entityId: "skill-1",
}));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_forked",
entityId: "skill-fork",
}));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_comment_created",
entityId: "comment-1",
}));
});
it("allows agents with canCreateAgents to mutate company skills", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
@@ -143,6 +143,169 @@ describeEmbeddedPostgres("companySkillService.list", () => {
});
});
it("filters store list results by category and creates version snapshots", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-versioned-skill-"));
cleanupDirs.add(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Versioned Skill\ncategories:\n - Memory\n---\n\n# Versioned Skill\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: `company/${companyId}/versioned-skill`,
slug: "versioned-skill",
name: "Versioned Skill",
description: "Tracks revisions.",
markdown: "# Versioned Skill",
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
categories: ["memory"],
tagline: "Tracks revisions",
});
const filtered = await svc.list(companyId, { categories: ["memory"], sort: "recent" });
expect(filtered.some((skill) => skill.id === skillId)).toBe(true);
expect(filtered.find((skill) => skill.id === skillId)).toMatchObject({
categories: ["memory"],
tagline: "Tracks revisions",
});
const version = await svc.createVersion(companyId, skillId, { label: "v1" }, { type: "user", userId: "board" });
expect(version).toMatchObject({
companySkillId: skillId,
revisionNumber: 1,
label: "v1",
authorUserId: "board",
});
expect(version.fileInventory).toEqual([
expect.objectContaining({
path: "SKILL.md",
kind: "skill",
content: expect.stringContaining("# Versioned Skill"),
}),
]);
await expect(svc.getVersion(companyId, skillId, version.id)).resolves.toMatchObject({ id: version.id });
});
it("tracks stars and skill comments with actor ownership", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: `company/${companyId}/discussion-skill`,
slug: "discussion-skill",
name: "Discussion Skill",
description: null,
markdown: "# Discussion Skill",
sourceType: "local_path",
sourceLocator: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
});
await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: true,
starCount: 1,
});
await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: true,
starCount: 1,
});
const comment = await svc.createComment(
companyId,
skillId,
{ body: "Looks useful." },
{ type: "user", userId: "board" },
);
expect(comment).toMatchObject({ body: "Looks useful.", authorUserId: "board" });
await expect(svc.updateComment(
companyId,
skillId,
comment.id,
{ body: "Looks very useful." },
{ type: "agent", agentId: randomUUID() },
)).rejects.toMatchObject({ status: 422 });
await expect(svc.deleteComment(companyId, skillId, comment.id, { type: "user", userId: "board" }))
.resolves.toMatchObject({ id: comment.id, deletedAt: expect.any(Date) });
await expect(svc.unstarSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: false,
starCount: 0,
});
});
it("validates version-aware desired skill selections", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const otherSkillId = randomUUID();
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pinned-skill-"));
cleanupDirs.add(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Pinned Skill\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values([
{
id: skillId,
companyId,
key: `company/${companyId}/pinned-skill`,
slug: "pinned-skill",
name: "Pinned Skill",
description: null,
markdown: "# Pinned Skill",
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
},
{
id: otherSkillId,
companyId,
key: `company/${companyId}/other-skill`,
slug: "other-skill",
name: "Other Skill",
description: null,
markdown: "# Other Skill",
sourceType: "local_path",
sourceLocator: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
},
]);
const version = await svc.createVersion(companyId, skillId, {}, { type: "user", userId: "board" });
await expect(svc.resolveRequestedSkillEntries(companyId, [
{ key: "pinned-skill", versionId: version.id },
])).resolves.toEqual([
{ key: `company/${companyId}/pinned-skill`, versionId: version.id },
]);
await expect(svc.resolveRequestedSkillEntries(companyId, [
{ key: "other-skill", versionId: version.id },
])).rejects.toMatchObject({ status: 422 });
});
it("preserves missing local-path skills that active agents still desire", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
+50 -17
View File
@@ -16,6 +16,7 @@ import {
normalizeIssueIdentifier,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
type AgentDesiredSkillEntry,
type AgentSkillSnapshot,
type InstanceSchedulerHeartbeatAgent,
upsertAgentInstructionsFileSchema,
@@ -1282,18 +1283,39 @@ export function agentRoutes(
function buildUnsupportedSkillSnapshot(
adapterType: string,
desiredSkills: string[] = [],
desiredSkillEntries: AgentDesiredSkillEntry[] = [],
): AgentSkillSnapshot {
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
return {
adapterType,
supported: false,
mode: "unsupported",
desiredSkills,
desiredSkillEntries,
entries: [],
warnings: ["This adapter does not implement skill sync yet."],
};
}
function normalizeDesiredSkillSelections(
requestedDesiredSkills: Array<string | AgentDesiredSkillEntry> | undefined,
): AgentDesiredSkillEntry[] | undefined {
if (!requestedDesiredSkills) return undefined;
const out = new Map<string, AgentDesiredSkillEntry>();
for (const value of requestedDesiredSkills) {
const entry = typeof value === "string"
? { key: value.trim(), versionId: null }
: { key: value.key.trim(), versionId: value.versionId ?? null };
if (!entry.key || out.has(entry.key)) continue;
out.set(entry.key, entry);
}
return Array.from(out.values());
}
function skillVersionSelectionMap(entries: Array<{ key: string; versionId: string | null }>) {
return new Map(entries.map((entry) => [entry.key, entry.versionId] as const));
}
// Legacy hardcoded set — used as fallback when adapter module does not
// declare requiresMaterializedRuntimeSkills explicitly.
const LEGACY_MATERIALIZED_SKILLS_SET = new Set([
@@ -1319,9 +1341,11 @@ export function agentRoutes(
materializeMissing?: boolean;
} = {},
) {
const preference = readPaperclipSkillSyncPreference(config);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: options.materializeMissing
?? shouldMaterializeRuntimeSkillsForAdapter(adapterType),
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
});
return {
...config,
@@ -1333,31 +1357,39 @@ export function agentRoutes(
companyId: string,
adapterType: string,
adapterConfig: Record<string, unknown>,
requestedDesiredSkills: string[] | undefined,
requestedDesiredSkills: AgentDesiredSkillEntry[] | undefined,
) {
if (!requestedDesiredSkills) {
return {
adapterConfig,
desiredSkills: null as string[] | null,
desiredSkillEntries: null as AgentDesiredSkillEntry[] | null,
runtimeSkillEntries: null as Awaited<ReturnType<typeof companySkills.listRuntimeSkillEntries>> | null,
};
}
const resolvedRequestedSkills = await companySkills.resolveRequestedSkillKeys(
const resolvedRequestedSkillEntries = await companySkills.resolveRequestedSkillEntries(
companyId,
requestedDesiredSkills,
);
const resolvedRequestedSkills = resolvedRequestedSkillEntries.map((entry) => entry.key);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries),
});
const requiredSkills = runtimeSkillEntries
.filter((entry) => entry.required)
.map((entry) => entry.key);
const desiredSkills = Array.from(new Set([...requiredSkills, ...resolvedRequestedSkills]));
const desiredSkillEntries = [
...requiredSkills.map((key) => ({ key, versionId: null })),
...resolvedRequestedSkillEntries,
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
return {
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkills),
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkillEntries),
desiredSkills,
desiredSkillEntries,
runtimeSkillEntries,
};
}
@@ -1574,9 +1606,14 @@ export function agentRoutes(
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, {
materializeMissing: false,
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
});
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
const desiredSkillEntries = [
...requiredSkills.map((key) => ({ key, versionId: null })),
...preference.desiredSkillEntries,
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries));
return;
}
@@ -1611,16 +1648,11 @@ export function agentRoutes(
}
await assertCanUpdateAgent(req, agent);
const requestedSkills = Array.from(
new Set(
(req.body.desiredSkills as string[])
.map((value) => value.trim())
.filter(Boolean),
),
);
const requestedSkills = normalizeDesiredSkillSelections(req.body.desiredSkills);
const {
adapterConfig: nextAdapterConfig,
desiredSkills,
desiredSkillEntries,
runtimeSkillEntries,
} = await resolveDesiredSkillAssignment(
agent.companyId,
@@ -1628,7 +1660,7 @@ export function agentRoutes(
agent.adapterConfig as Record<string, unknown>,
requestedSkills,
);
if (!desiredSkills || !runtimeSkillEntries) {
if (!desiredSkills || !desiredSkillEntries || !runtimeSkillEntries) {
throw unprocessable("Skill sync requires desiredSkills.");
}
const actor = getActorInfo(req);
@@ -1669,7 +1701,7 @@ export function agentRoutes(
adapterType: updated.adapterType,
config: runtimeSkillConfig,
})
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills);
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkillEntries);
await logActivity(db, {
companyId: updated.companyId,
@@ -1683,6 +1715,7 @@ export function agentRoutes(
details: {
adapterType: updated.adapterType,
desiredSkills,
desiredSkillEntries,
mode: snapshot.mode,
supported: snapshot.supported,
entryCount: snapshot.entries.length,
@@ -2093,7 +2126,7 @@ export function agentRoutes(
companyId,
hireInput.adapterType,
requestedAdapterConfig,
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined),
);
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
companyId,
@@ -2279,7 +2312,7 @@ export function agentRoutes(
companyId,
createInput.adapterType,
requestedAdapterConfig,
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined),
);
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
companyId,
+231 -2
View File
@@ -2,13 +2,18 @@ import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
catalogSkillListQuerySchema,
companySkillCommentCreateSchema,
companySkillCommentUpdateSchema,
companySkillCreateSchema,
companySkillFileUpdateSchema,
companySkillForkSchema,
companySkillImportSchema,
companySkillInstallCatalogSchema,
companySkillInstallUpdateSchema,
companySkillListQuerySchema,
companySkillProjectScanRequestSchema,
companySkillResetSchema,
companySkillVersionCreateSchema,
} from "@paperclipai/shared";
import { trackSkillImported } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
@@ -63,6 +68,22 @@ export function companySkillRoutes(db: Db) {
return undefined;
}
function queryStringArray(value: unknown): string[] {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string");
return [];
}
function skillActor(req: Request) {
if (req.actor.type === "agent") {
return { type: "agent" as const, agentId: req.actor.agentId ?? null };
}
if (req.actor.type === "board") {
return { type: "user" as const, userId: req.actor.userId ?? "board" };
}
return { type: "system" as const };
}
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
@@ -118,15 +139,30 @@ export function companySkillRoutes(db: Db) {
router.get("/companies/:companyId/skills", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
const result = await svc.list(companyId, companySkillListQuerySchema.parse({
q: firstQueryString(req.query.q),
sort: firstQueryString(req.query.sort),
categories: [
...queryStringArray(req.query.category),
...queryStringArray(req.query.categories),
...queryStringArray(req.query["categories[]"]),
],
scope: firstQueryString(req.query.scope),
}));
res.json(result);
});
router.get("/companies/:companyId/skills/categories", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.categoryCounts(companyId));
});
router.get("/companies/:companyId/skills/:skillId", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.detail(companyId, skillId);
const result = await svc.detail(companyId, skillId, skillActor(req));
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
@@ -134,6 +170,199 @@ export function companySkillRoutes(db: Db) {
res.json(result);
});
router.get("/companies/:companyId/skills/:skillId/versions", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.listVersions(companyId, skillId));
});
router.get("/companies/:companyId/skills/:skillId/versions/:versionId", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
const versionId = req.params.versionId as string;
assertCompanyAccess(req, companyId);
const result = await svc.getVersion(companyId, skillId, versionId);
if (!result) {
res.status(404).json({ error: "Skill version not found" });
return;
}
res.json(result);
});
router.post(
"/companies/:companyId/skills/:skillId/versions",
validate(companySkillVersionCreateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_version_created",
entityType: "company_skill_version",
entityId: result.id,
details: {
skillId,
revisionNumber: result.revisionNumber,
label: result.label,
},
});
res.status(201).json(result);
},
);
router.post("/companies/:companyId/skills/:skillId/star", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.starSkill(companyId, skillId, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_starred",
entityType: "company_skill",
entityId: skillId,
details: { starCount: result.starCount },
});
res.json(result);
});
router.delete("/companies/:companyId/skills/:skillId/star", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.unstarSkill(companyId, skillId, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_unstarred",
entityType: "company_skill",
entityId: skillId,
details: { starCount: result.starCount },
});
res.json(result);
});
router.post(
"/companies/:companyId/skills/:skillId/fork",
validate(companySkillForkSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_forked",
entityType: "company_skill",
entityId: result.id,
details: {
sourceSkillId: skillId,
slug: result.slug,
name: result.name,
},
});
res.status(201).json(result);
},
);
router.get("/companies/:companyId/skills/:skillId/comments", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.listComments(companyId, skillId));
});
router.post(
"/companies/:companyId/skills/:skillId/comments",
validate(companySkillCommentCreateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.createComment(companyId, skillId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_comment_created",
entityType: "company_skill_comment",
entityId: result.id,
details: { skillId, parentCommentId: result.parentCommentId },
});
res.status(201).json(result);
},
);
router.patch(
"/companies/:companyId/skills/:skillId/comments/:commentId",
validate(companySkillCommentUpdateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
const commentId = req.params.commentId as string;
assertCompanyAccess(req, companyId);
const result = await svc.updateComment(companyId, skillId, commentId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_comment_updated",
entityType: "company_skill_comment",
entityId: result.id,
details: { skillId },
});
res.json(result);
},
);
router.delete("/companies/:companyId/skills/:skillId/comments/:commentId", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
const commentId = req.params.commentId as string;
assertCompanyAccess(req, companyId);
const result = await svc.deleteComment(companyId, skillId, commentId, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_comment_deleted",
entityType: "company_skill_comment",
entityId: result.id,
details: { skillId },
});
res.json(result);
});
router.get("/companies/:companyId/skills/:skillId/update-status", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
File diff suppressed because it is too large. Load diff