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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ coverage
playwright-report
test-results
.playwright
.agents
/skills
skills-lock.json
.claude
183 changes: 183 additions & 0 deletions convex/skills.dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { describe, expect, it, vi } from "vitest";

vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));

import { listDashboardPaginated } from "./skills";

type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};

const handler = (
listDashboardPaginated as unknown as WrappedHandler<
{
ownerUserId?: string;
ownerPublisherId?: string;
paginationOpts: { cursor: string | null; numItems: number };
},
{ page: Array<{ slug: string }>; isDone: boolean; continueCursor: string }
>
)._handler;

function makeSkill(slug: string, overrides: Record<string, unknown> = {}) {
return {
_id: `skills:${slug}`,
_creationTime: 1,
slug,
displayName: slug.charAt(0).toUpperCase() + slug.slice(1),
summary: `${slug} integration.`,
ownerUserId: "users:owner",
ownerPublisherId: undefined,
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: undefined,
tags: {},
badges: undefined,
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 1, comments: 0 },
createdAt: 1,
updatedAt: 2,
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: [],
moderationReason: undefined,
...overrides,
};
}

function makeCtx(skills: ReturnType<typeof makeSkill>[]) {
return {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return { _id: "users:owner", _creationTime: 1, handle: "owner", displayName: "Owner" };
}
if (id === "publishers:pub") {
return {
_id: "publishers:pub",
_creationTime: 1,
kind: "user",
handle: "owner",
displayName: "Owner",
linkedUserId: "users:owner",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(null),
})),
};
}
if (table === "skills") {
return {
withIndex: vi.fn(() => ({
filter: vi.fn(() => ({
order: vi.fn(() => ({
paginate: vi.fn().mockResolvedValue({
page: skills,
isDone: true,
continueCursor: "",
}),
})),
})),
})),
};
}
if (table === "skillBadges") {
return {
withIndex: vi.fn(() => ({
take: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
}

const paginationOpts = { cursor: null, numItems: 50 };

describe("skills.listDashboardPaginated", () => {
it("returns paginated skills for ownerUserId", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const skills = [makeSkill("slack"), makeSkill("stripe")];
const ctx = makeCtx(skills);

const result = await handler(ctx as never, {
ownerUserId: "users:owner",
paginationOpts,
} as never);

expect(result.page).toHaveLength(2);
expect(result.page.map((s) => s.slug)).toEqual(["slack", "stripe"]);
expect(result.isDone).toBe(true);
});

it("returns paginated skills for ownerPublisherId", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const skills = [makeSkill("github")];
const ctx = makeCtx(skills);

const result = await handler(ctx as never, {
ownerPublisherId: "publishers:pub",
paginationOpts,
} as never);

expect(result.page).toEqual([expect.objectContaining({ slug: "github" })]);
});

it("returns empty when no owner specified", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeCtx([makeSkill("a")]);

const result = await handler(ctx as never, { paginationOpts } as never);

expect(result.page).toEqual([]);
expect(result.isDone).toBe(true);
});

it("includes pending-review skills for own dashboard", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const skills = [
makeSkill("pending-skill", {
moderationStatus: "hidden",
moderationReason: "pending.scan",
}),
];
const ctx = makeCtx(skills);

const result = await handler(ctx as never, {
ownerUserId: "users:owner",
paginationOpts,
} as never);

expect(result.page).toHaveLength(1);
expect(result.page[0]).toHaveProperty("pendingReview", true);
});

it("filters out non-visible skills for non-owner callers", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:other" as never);
const skills = [
makeSkill("hidden-skill", {
moderationStatus: "hidden",
moderationReason: "pending.scan",
}),
];
const ctx = makeCtx(skills);

const result = await handler(ctx as never, {
ownerUserId: "users:owner",
paginationOpts,
} as never);

expect(result.page).toEqual([]);
});
});
102 changes: 102 additions & 0 deletions convex/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,108 @@ export const list = query({
},
});

/** Paginated dashboard query — used by the Publisher Dashboard to paginate
* large skill sets with client-side search. */
export const listDashboardPaginated = query({
args: {
ownerUserId: v.optional(v.id("users")),
ownerPublisherId: v.optional(v.id("publishers")),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);

// --- Publisher path (includes legacy user-owned skills) ----------------
const ownerPublisherId = args.ownerPublisherId;
if (ownerPublisherId) {
const ownerPublisher = await ctx.db.get(ownerPublisherId);
const membership =
userId &&
(await ctx.db
.query("publisherMembers")
.withIndex("by_publisher_user", (q) =>
q.eq("publisherId", ownerPublisherId).eq("userId", userId),
)
.unique());
const isOwnDashboard = Boolean(
membership || (userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId),
);

const result = await ctx.db
.query("skills")
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
Comment on lines +2223 to +2225
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include linked-user legacy skills in publisher pagination

listDashboardPaginated only paginates by_owner_publisher, but the existing skills.list behavior for personal publishers explicitly merges linked-user legacy rows (ownerPublisherId unset) so pre-backfill skills stay visible. This new path drops that fallback, so requests using ownerPublisherId can silently miss legacy personal skills and loadMore will never recover them because they are on a different index path.

Useful? React with 👍 / 👎.

.filter((q) => q.eq(q.field("softDeletedAt"), undefined))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid filtering soft-deletes after owner index scans

Both paginated branches call .filter(q => q.eq(q.field("softDeletedAt"), undefined)) after by_owner/by_owner_publisher, which forces Convex to read through deleted rows inside each owner partition before serving a page. For owners with many soft-deleted skills this can significantly inflate document reads and can hit read limits; this path should paginate on composite active-owner indexes instead of post-index filters.

Useful? React with 👍 / 👎.

.order("desc")
.paginate(args.paginationOpts);

const filtered = isOwnDashboard
? result.page
: await filterSkillsByActiveOwner(ctx, result.page);
const withBadges = await attachBadgesToSkills(ctx, filtered);
const page = toDashboardPage(withBadges, isOwnDashboard);
return { ...result, page };
}

// --- User path --------------------------------------------------------
const ownerUserId = args.ownerUserId;
if (ownerUserId) {
const isOwnDashboard = Boolean(userId && userId === ownerUserId);

const result = await ctx.db
.query("skills")
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId))
.filter((q) => q.eq(q.field("softDeletedAt"), undefined))
.order("desc")
.paginate(args.paginationOpts);

const filtered = isOwnDashboard
? result.page
: await filterSkillsByActiveOwner(ctx, result.page);
const withBadges = await attachBadgesToSkills(ctx, filtered);
const page = toDashboardPage(withBadges, isOwnDashboard);
return { ...result, page };
}

return { page: [], isDone: true as const, continueCursor: "" };
},
});

/** Map skills to public shape, including pending-review items for owners. */
function toDashboardPage(skills: Doc<"skills">[], isOwnDashboard: boolean) {
return skills
.map((skill) => {
const publicSkill = toPublicSkill(skill);
if (publicSkill) return publicSkill;
if (isOwnDashboard) {
const isPending =
skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan";
if (isPending) {
const { badges } = skill;
return {
_id: skill._id,
_creationTime: skill._creationTime,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
canonicalSkillId: skill.canonicalSkillId,
forkOf: skill.forkOf,
latestVersionId: skill.latestVersionId,
tags: skill.tags,
badges,
stats: skill.stats,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
pendingReview: true as const,
};
}
}
return null;
})
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
}

export const listWithLatest = query({
args: {
batch: v.optional(v.string()),
Expand Down
37 changes: 29 additions & 8 deletions src/routes/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { usePaginatedQuery, useQuery } from "convex/react";
import {
AlertTriangle,
ArrowDownToLine,
CheckCircle2,
Clock,
GitBranch,
Loader2,
Package,
Plug,
ShieldCheck,
Expand Down Expand Up @@ -83,16 +84,23 @@ function Dashboard() {
const selectedPublisher =
publishers?.find((entry) => entry.publisher._id === selectedPublisherId) ?? null;

const mySkills = useQuery(
api.skills.list,
const skillsQueryArgs =
selectedPublisher?.publisher.kind === "user" && me?._id
? { ownerUserId: me._id, limit: 100 }
? { ownerUserId: me._id }
: selectedPublisherId
? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"], limit: 100 }
? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"] }
: me?._id
? { ownerUserId: me._id, limit: 100 }
: "skip",
) as DashboardSkill[] | undefined;
? { ownerUserId: me._id }
: "skip";
const {
results: paginatedSkills,
status: skillsStatus,
loadMore,
} = usePaginatedQuery(api.skills.listDashboardPaginated, skillsQueryArgs, {
initialNumItems: 50,
});
const mySkills = paginatedSkills as DashboardSkill[] | undefined;

const myPackages = useQuery(
api.packages.list,
selectedPublisherId
Expand Down Expand Up @@ -197,6 +205,19 @@ function Dashboard() {
))}
</div>
)}
{skillsStatus === "CanLoadMore" && (
<div className="dashboard-load-more">
<button className="btn" onClick={() => loadMore(50)}>
Load More
</button>
</div>
)}
{skillsStatus === "LoadingMore" && (
<div className="dashboard-load-more">
<Loader2 className="dashboard-spinner" size={20} />
<span>Loading more skills...</span>
</div>
)}
</section>

<section className="dashboard-collection-block">
Expand Down
18 changes: 18 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -4050,6 +4050,24 @@ html.theme-transition::view-transition-new(theme) {
color: var(--color-text);
}

.dashboard-load-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
color: var(--muted);
font-size: 0.88rem;
}

@keyframes spin {
to { transform: rotate(360deg); }
}

.dashboard-spinner {
animation: spin 1s linear infinite;
}

.dashboard-list {
border: 1px solid var(--card-border);
border-radius: 14px;
Expand Down