Compare commits

...
15 changed files with 1133 additions and 84 deletions
+20 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Doc } from "../_generated/dataModel";
import { toPublicSkill } from "./public";
import { toPublicSkill, toPublicUser } from "./public";
function makeSkill(overrides: Partial<Doc<"skills">> = {}): Doc<"skills"> {
return {
@@ -43,6 +43,25 @@ function makeSkill(overrides: Partial<Doc<"skills">> = {}): Doc<"skills"> {
} as Doc<"skills">;
}
describe("public user mapping", () => {
it("normalizes public handles to lowercase", () => {
const user = {
_id: "users:1",
_creationTime: 1,
handle: "JaredforReal",
name: "JaredforReal",
displayName: "Jared Wen",
image: undefined,
bio: undefined,
} as Doc<"users">;
expect(toPublicUser(user)).toMatchObject({
handle: "jaredforreal",
name: "JaredforReal",
});
});
});
describe("public skill mapping", () => {
it("normalizes stats when legacy skill record is missing stats object", () => {
const legacySkill = makeSkill({
+6 -1
View File
@@ -80,12 +80,17 @@ export type PublicSoul = Pick<
| "updatedAt"
>;
function normalizePublicHandle(handle: string | undefined | null) {
const normalized = handle?.trim().toLowerCase();
return normalized ? normalized : undefined;
}
export function toPublicUser(user: Doc<"users"> | null | undefined): PublicUser | null {
if (!user || user.deletedAt || user.deactivatedAt) return null;
return {
_id: user._id,
_creationTime: user._creationTime,
handle: user.handle,
handle: normalizePublicHandle(user.handle),
name: user.name,
displayName: user.displayName,
image: user.image,
+1 -1
View File
@@ -81,7 +81,7 @@ export function buildDiscordPayload(
}
export function buildSkillUrl(skill: WebhookSkillPayload, siteUrl: string) {
const owner = skill.ownerHandle?.trim();
const owner = skill.ownerHandle?.trim().toLowerCase();
if (owner) return `${siteUrl}/${owner}/${skill.slug}`;
return `${siteUrl}/skills/${skill.slug}`;
}
+1 -6
View File
@@ -844,12 +844,7 @@ const packageCapabilitySearchDigest = defineTable({
"executesCode",
"updatedAt",
])
.index("by_active_family_tag_updated", [
"softDeletedAt",
"family",
"capabilityTag",
"updatedAt",
])
.index("by_active_family_tag_updated", ["softDeletedAt", "family", "capabilityTag", "updatedAt"])
.index("by_active_family_tag_executes_updated", [
"softDeletedAt",
"family",
+17 -9
View File
@@ -2,9 +2,9 @@ import { getAuthUserId } from "@convex-dev/auth/server";
import { getPage, type IndexKey, paginator } from "convex-helpers/server/pagination";
import { paginationOptsValidator } from "convex/server";
import { ConvexError, v, type Value } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { internal } from "./_generated/api";
import {
action,
internalAction,
@@ -453,7 +453,7 @@ function buildConflictingSkillUrl(
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
) {
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
const ownerParam = owner.handle?.trim() || String(owner._id);
const ownerParam = owner.handle?.trim().toLowerCase() || String(owner._id);
if (!ownerParam) return null;
return `/${encodeURIComponent(ownerParam)}/${encodeURIComponent(skill.slug)}`;
}
@@ -2055,7 +2055,8 @@ export const list = query({
)
.unique());
const isOwnDashboard = Boolean(
membership || (userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId),
membership ||
(userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId),
);
const scopedEntries = await ctx.db
.query("skills")
@@ -2775,12 +2776,13 @@ function decodeSkillCatalogCursor(raw: string | null | undefined): SkillCatalogC
return { cursor: raw, offset: 0, pageSize: null, done: false };
}
try {
const parsed = JSON.parse(raw.slice(SKILL_CATALOG_CURSOR_PREFIX.length)) as Partial<SkillCatalogCursorState>;
const parsed = JSON.parse(
raw.slice(SKILL_CATALOG_CURSOR_PREFIX.length),
) as Partial<SkillCatalogCursorState>;
return {
cursor: typeof parsed.cursor === "string" ? parsed.cursor : null,
offset: typeof parsed.offset === "number" && parsed.offset > 0 ? parsed.offset : 0,
pageSize:
typeof parsed.pageSize === "number" && parsed.pageSize > 0 ? parsed.pageSize : null,
pageSize: typeof parsed.pageSize === "number" && parsed.pageSize > 0 ? parsed.pageSize : null,
done: parsed.done === true,
};
} catch {
@@ -2864,7 +2866,9 @@ function scoreSkillCatalogResult(digest: Doc<"skillSearchDigest">, queryText: st
export const listPackageCatalogPage = query({
args: {
channel: v.optional(v.union(v.literal("official"), v.literal("community"), v.literal("private"))),
channel: v.optional(
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
@@ -2894,7 +2898,9 @@ export const listPackageCatalogPage = query({
loops += 1;
const effectivePageSize = Math.min(
remainingScanBudget,
offset > 0 && pageSize ? Math.max(pageSize, offset + 1) : Math.max(targetCount * 3, targetCount),
offset > 0 && pageSize
? Math.max(pageSize, offset + 1)
: Math.max(targetCount * 3, targetCount),
);
if (effectivePageSize <= 0) break;
remainingScanBudget -= effectivePageSize;
@@ -2948,7 +2954,9 @@ export const searchPackageCatalogPublic = query({
args: {
query: v.string(),
limit: v.optional(v.number()),
channel: v.optional(v.union(v.literal("official"), v.literal("community"), v.literal("private"))),
channel: v.optional(
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
+558 -4
View File
@@ -17,7 +17,9 @@ const { requireUser } = await import("./lib/access");
const { getAuthUserId } = await import("@convex-dev/auth/server");
const { insertStatEvent } = await import("./skillStatEvents");
const {
backfillCanonicalHandlesInternal,
ensureHandler,
getByHandle,
list,
searchInternal,
banUserInternal,
@@ -32,6 +34,14 @@ type WrappedHandler<TArgs, TResult> = {
};
const meHandler = (me as unknown as WrappedHandler<Record<string, never>, unknown>)._handler;
const getByHandleHandler = (getByHandle as unknown as WrappedHandler<{ handle: string }, unknown>)
._handler;
const backfillCanonicalHandlesInternalHandler = (
backfillCanonicalHandlesInternal as unknown as WrappedHandler<
{ cursor?: string; batchSize?: number; dryRun?: boolean },
unknown
>
)._handler;
function makeCtx() {
const patch = vi.fn();
@@ -132,6 +142,200 @@ function makeListCtx(users: Array<Record<string, unknown>>) {
};
}
function makeHandleBackfillCtx(
seedUsers: Array<Record<string, unknown>>,
seedPublishers: Array<Record<string, unknown>> = [],
) {
const userRows = new Map(seedUsers.map((row) => [String(row._id), { ...row }]));
const publisherRows = new Map(seedPublishers.map((row) => [String(row._id), { ...row }]));
const publisherMembers: Array<Record<string, unknown>> = [];
const patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
if (userRows.has(id)) {
userRows.set(id, { ...userRows.get(id), ...value });
return;
}
if (publisherRows.has(id)) {
publisherRows.set(id, { ...publisherRows.get(id), ...value });
return;
}
const member = publisherMembers.find((entry) => entry._id === id);
if (member) Object.assign(member, value);
});
const get = vi.fn(async (id: string) => {
const key = String(id);
return userRows.get(key) ?? publisherRows.get(key) ?? null;
});
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
if (table === "publishers") {
const handle = typeof value.handle === "string" ? value.handle : "user";
const id = `publishers:${handle}`;
publisherRows.set(id, { _id: id, _creationTime: 1, ...value });
return id;
}
if (table === "publisherMembers") {
const id = `publisherMembers:${publisherMembers.length + 1}`;
publisherMembers.push({ _id: id, ...value });
return id;
}
if (table === "auditLogs") return "auditLogs:1";
throw new Error(`Unexpected insert table ${table}`);
});
const query = vi.fn((table: string) => {
if (table === "reservedHandles") {
return {
withIndex: (name: string) => {
if (name !== "by_handle_active_updatedAt") {
throw new Error(`Unexpected reservedHandles index ${name}`);
}
return { order: () => ({ take: vi.fn(async () => []) }) };
},
};
}
if (table === "users") {
return {
withIndex: (
name: string,
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
let handle = "";
const q = {
eq: (field: string, value: string) => {
if (field === "handle") handle = value;
return q;
},
};
builder?.(q);
return {
unique: vi.fn(async () => {
return [...userRows.values()].find((row) => row.handle === handle) ?? null;
}),
};
},
order: (direction: string) => {
if (direction !== "asc") throw new Error(`Unexpected users order ${direction}`);
return {
paginate: vi.fn(
async ({ cursor, numItems }: { cursor?: string | null; numItems: number }) => {
const rows = [...userRows.values()].sort(
(a, b) => Number(a._creationTime ?? 0) - Number(b._creationTime ?? 0),
);
const start = cursor ? Number.parseInt(cursor, 10) : 0;
const page = rows.slice(start, start + numItems);
const nextOffset = start + page.length;
return {
page,
isDone: nextOffset >= rows.length,
continueCursor: nextOffset >= rows.length ? null : String(nextOffset),
};
},
),
};
},
};
}
if (table === "publishers") {
return {
withIndex: (
name: string,
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
let handle = "";
let linkedUserId = "";
const q = {
eq: (field: string, value: string) => {
if (field === "handle") handle = value;
if (field === "linkedUserId") linkedUserId = value;
return q;
},
};
builder?.(q);
if (name === "by_handle") {
return {
unique: vi.fn(async () => {
return [...publisherRows.values()].find((row) => row.handle === handle) ?? null;
}),
};
}
if (name === "by_linked_user") {
return {
unique: vi.fn(async () => {
return (
[...publisherRows.values()].find((row) => row.linkedUserId === linkedUserId) ??
null
);
}),
};
}
throw new Error(`Unexpected publishers index ${name}`);
},
};
}
if (table === "publisherMembers") {
return {
withIndex: (
name: string,
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
if (name !== "by_publisher_user") {
throw new Error(`Unexpected publisherMembers index ${name}`);
}
let publisherId = "";
let userId = "";
const q = {
eq: (field: string, value: string) => {
if (field === "publisherId") publisherId = value;
if (field === "userId") userId = value;
return q;
},
};
builder?.(q);
return {
unique: vi.fn(async () => {
return (
publisherMembers.find(
(row) => row.publisherId === publisherId && row.userId === userId,
) ?? null
);
}),
};
},
};
}
if (table === "packages" || table === "skills") {
return {
withIndex: (name: string) => {
if (name !== "by_owner_publisher") {
throw new Error(`Unexpected ${table} index ${name}`);
}
return { collect: vi.fn(async () => []) };
},
};
}
throw new Error(`Unexpected table ${table}`);
});
return {
ctx: { db: { get, insert, patch, query, normalizeId: vi.fn() } } as never,
userRows,
publisherRows,
publisherMembers,
patch,
insert,
get,
query,
};
}
function makeBanCtx() {
const patch = vi.fn();
const insert = vi.fn();
@@ -204,6 +408,28 @@ describe("ensureHandler", () => {
});
});
it("normalizes a mixed-case handle to lowercase", async () => {
const { ctx, patch } = makeCtx();
vi.mocked(requireUser).mockResolvedValue({
userId: "users:case",
user: {
_creationTime: 1,
handle: "JaredforReal",
displayName: "Jared Wen",
name: "JaredforReal",
role: "user",
createdAt: 1,
},
} as never);
await ensureHandler(ctx);
expect(patch).toHaveBeenCalledWith("users:case", {
handle: "jaredforreal",
updatedAt: expect.any(Number),
});
});
it("does not override a custom display name when syncing handle", async () => {
const { ctx, patch } = makeCtx();
vi.mocked(requireUser).mockResolvedValue({
@@ -303,7 +529,6 @@ describe("ensureHandler", () => {
await ensureHandler(ctx);
expect(patch).toHaveBeenCalledWith("users:admin", {
displayName: "steipete",
role: "admin",
updatedAt: expect.any(Number),
});
@@ -335,6 +560,32 @@ describe("ensureHandler", () => {
});
});
it("preserves GitHub login casing for derived display names", async () => {
const { ctx, patch } = makeCtx();
vi.mocked(requireUser).mockResolvedValue({
userId: "users:github",
user: {
_creationTime: 1,
handle: undefined,
displayName: undefined,
name: "JohnDoe",
email: undefined,
role: undefined,
createdAt: undefined,
},
} as never);
await ensureHandler(ctx);
expect(patch).toHaveBeenCalledWith("users:github", {
handle: "johndoe",
displayName: "JohnDoe",
role: "user",
createdAt: 1,
updatedAt: expect.any(Number),
});
});
it("does not auto-claim a reserved handle for another user", async () => {
const { ctx, patch, query } = makeCtx();
query.mockImplementation(((table: string) => {
@@ -377,7 +628,10 @@ describe("ensureHandler", () => {
await ensureHandler(ctx);
expect(patch).not.toHaveBeenCalled();
expect(patch).toHaveBeenCalledWith("users:other", {
displayName: "openclaw",
updatedAt: expect.any(Number),
});
});
it("does not auto-claim a handle already owned by an org publisher", async () => {
@@ -395,7 +649,10 @@ describe("ensureHandler", () => {
}
if (table === "publishers") {
return {
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
withIndex: (
name: string,
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
let handle = "";
let linkedUserId = "";
const q = {
@@ -501,6 +758,68 @@ describe("me", () => {
});
describe("users.syncGitHubProfileInternal", () => {
it("normalizes a mixed-case stored handle to lowercase", async () => {
const { ctx, get, patch } = makeCtx();
get.mockResolvedValue({
_id: "users:other",
handle: "JaredforReal",
displayName: "Jared Wen",
name: "JaredforReal",
});
const handler = (
syncGitHubProfileInternal as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<void>;
}
)._handler;
await handler(ctx, {
userId: "users:other",
name: "JaredforReal",
syncedAt: 10,
});
expect(patch).toHaveBeenCalledWith(
"users:other",
expect.objectContaining({
githubProfileSyncedAt: 10,
handle: "jaredforreal",
}),
);
});
it("preserves GitHub login casing for derived display names", async () => {
const { ctx, get, patch } = makeCtx();
get.mockResolvedValue({
_id: "users:other",
handle: "old-handle",
displayName: "old-handle",
name: "old-handle",
});
const handler = (
syncGitHubProfileInternal as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<void>;
}
)._handler;
await handler(ctx, {
userId: "users:other",
name: "JohnDoe",
syncedAt: 10,
});
expect(patch).toHaveBeenCalledWith(
"users:other",
expect.objectContaining({
githubProfileSyncedAt: 10,
name: "JohnDoe",
handle: "johndoe",
displayName: "JohnDoe",
}),
);
});
it("keeps a derived handle unchanged when the new login is reserved", async () => {
const { ctx, get, patch, query } = makeCtx();
get.mockResolvedValue({
@@ -580,7 +899,10 @@ describe("users.syncGitHubProfileInternal", () => {
}
if (table === "publishers") {
return {
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
withIndex: (
name: string,
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
let handle = "";
let linkedUserId = "";
const q = {
@@ -673,6 +995,238 @@ describe("users.syncGitHubProfileInternal", () => {
});
});
describe("users.getByHandle", () => {
it("resolves lowercase lookups through the linked publisher handle", async () => {
const user = {
_id: "users:1",
_creationTime: 1,
handle: "JaredforReal",
name: "JaredforReal",
displayName: "Jared Wen",
image: undefined,
bio: undefined,
};
const get = vi.fn(async (id: string) => (id === "users:1" ? user : null));
const query = vi.fn((table: string) => {
if (table === "users") {
return {
withIndex: (
_name: string,
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
let handle = "";
const q = {
eq: (field: string, value: string) => {
if (field === "handle") handle = value;
return q;
},
};
builder(q);
return {
unique: vi.fn(async () => (handle === "JaredforReal" ? user : null)),
};
},
};
}
if (table === "publishers") {
return {
withIndex: (
_name: string,
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
let handle = "";
const q = {
eq: (field: string, value: string) => {
if (field === "handle") handle = value;
return q;
},
};
builder(q);
return {
unique: vi.fn(async () =>
handle === "jaredforreal"
? {
_id: "publishers:jaredforreal",
kind: "user",
handle: "jaredforreal",
linkedUserId: "users:1",
}
: null,
),
};
},
};
}
throw new Error(`Unexpected table ${table}`);
});
const result = (await getByHandleHandler(
{ db: { get, query } },
{ handle: "jaredforreal" },
)) as { handle: string | null } | null;
expect(result).toMatchObject({ handle: "jaredforreal" });
});
it("falls back to a bounded user scan when no publisher row exists", async () => {
const user = {
_id: "users:1",
_creationTime: 1,
handle: "JaredforReal",
name: "JaredforReal",
displayName: "Jared Wen",
image: undefined,
bio: undefined,
};
const get = vi.fn(async () => null);
const query = vi.fn((table: string) => {
if (table === "users") {
return {
withIndex: (
_name: string,
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
let handle = "";
const q = {
eq: (field: string, value: string) => {
if (field === "handle") handle = value;
return q;
},
};
builder(q);
return {
unique: vi.fn(async () => (handle === "JaredforReal" ? user : null)),
};
},
order: (direction: string) => {
if (direction !== "asc") throw new Error(`Unexpected users order ${direction}`);
return {
paginate: vi.fn(async () => ({
page: [user],
isDone: true,
continueCursor: null,
})),
};
},
};
}
if (table === "publishers") {
return {
withIndex: () => ({ unique: vi.fn(async () => null) }),
};
}
throw new Error(`Unexpected table ${table}`);
});
const result = (await getByHandleHandler(
{ db: { get, query } },
{ handle: "jaredforreal" },
)) as { handle: string | null } | null;
expect(result).toMatchObject({ handle: "jaredforreal" });
});
});
describe("users.backfillCanonicalHandlesInternal", () => {
it("normalizes mixed-case handles and syncs personal publishers", async () => {
const { ctx, publisherRows, userRows } = makeHandleBackfillCtx(
[
{
_id: "users:1",
_creationTime: 1,
handle: "JaredforReal",
name: "JaredforReal",
displayName: "Jared Wen",
personalPublisherId: "publishers:jaredforreal",
role: "user",
createdAt: 1,
updatedAt: 1,
},
],
[
{
_id: "publishers:jaredforreal",
_creationTime: 1,
kind: "user",
handle: "JaredforReal",
displayName: "Jared Wen",
linkedUserId: "users:1",
createdAt: 1,
updatedAt: 1,
},
],
);
const result = (await backfillCanonicalHandlesInternalHandler(ctx, {
batchSize: 10,
})) as {
scanned: number;
normalizedUsers: number;
syncedPublishers: number;
skippedUsers: number;
cursor: string | null;
isDone: boolean;
dryRun: boolean;
};
expect(result).toEqual({
scanned: 1,
normalizedUsers: 1,
syncedPublishers: 1,
skippedUsers: 0,
cursor: null,
isDone: true,
dryRun: false,
ok: true,
});
expect(userRows.get("users:1")).toMatchObject({
handle: "jaredforreal",
personalPublisherId: "publishers:jaredforreal",
});
expect(publisherRows.get("publishers:jaredforreal")).toMatchObject({
handle: "jaredforreal",
linkedUserId: "users:1",
kind: "user",
});
});
it("supports dry-run without writing changes", async () => {
const { ctx, patch, insert, publisherRows, userRows } = makeHandleBackfillCtx([
{
_id: "users:1",
_creationTime: 1,
handle: "JaredforReal",
name: "JaredforReal",
displayName: "Jared Wen",
role: "user",
createdAt: 1,
updatedAt: 1,
},
]);
const result = (await backfillCanonicalHandlesInternalHandler(ctx, {
batchSize: 10,
dryRun: true,
})) as {
normalizedUsers: number;
syncedPublishers: number;
dryRun: boolean;
};
expect(result).toMatchObject({
normalizedUsers: 1,
syncedPublishers: 1,
dryRun: true,
});
expect(patch).not.toHaveBeenCalled();
expect(insert).not.toHaveBeenCalled();
expect(userRows.get("users:1")).toMatchObject({ handle: "JaredforReal" });
expect(publisherRows.size).toBe(0);
});
});
describe("users.list", () => {
afterEach(() => {
vi.mocked(requireUser).mockReset();
+240 -36
View File
@@ -1,13 +1,13 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx } from "./_generated/server";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { internal } from "./_generated/api";
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
import {
getLatestActiveReservedHandle,
isHandleReservedForAnotherUser,
@@ -22,6 +22,8 @@ const ADMIN_HANDLE = "steipete";
const MAX_USER_LIST_LIMIT = 200;
const MAX_USER_SEARCH_SCAN = 5_000;
const MIN_USER_SEARCH_SCAN = 500;
const DEFAULT_HANDLE_BACKFILL_BATCH_SIZE = 100;
const MAX_HANDLE_BACKFILL_BATCH_SIZE = 500;
export const getById = query({
args: { userId: v.id("users") },
@@ -33,15 +35,77 @@ export const getByIdInternal = internalQuery({
handler: async (ctx, args) => ctx.db.get(args.userId),
});
export const getByHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
const normalizedHandle = normalizeReservedHandle(args.handle);
if (!normalizedHandle) return null;
return await ctx.db
async function scanUsersByNormalizedHandle(
ctx: Pick<QueryCtx | MutationCtx, "db">,
normalizedHandle: string,
) {
let cursor: string | null = null;
let scanned = 0;
while (scanned < MAX_USER_SEARCH_SCAN) {
const pageSize = Math.min(500, MAX_USER_SEARCH_SCAN - scanned);
const result = await ctx.db
.query("users")
.order("asc")
.paginate({ cursor, numItems: pageSize });
scanned += result.page.length;
const match = result.page.find(
(user) =>
!user.deletedAt &&
!user.deactivatedAt &&
normalizeReservedHandle(user.handle) === normalizedHandle,
);
if (match) return match;
if (result.isDone || !result.continueCursor) return null;
cursor = result.continueCursor;
}
return null;
}
async function getUserByHandleCaseAware(
ctx: Pick<QueryCtx | MutationCtx, "db">,
handle: string | undefined | null,
) {
const trimmedHandle = handle?.trim();
const normalizedHandle = normalizeReservedHandle(handle);
if (!trimmedHandle || !normalizedHandle) return null;
const exactMatch = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", trimmedHandle))
.unique();
if (exactMatch && !exactMatch.deletedAt && !exactMatch.deactivatedAt) return exactMatch;
if (trimmedHandle !== normalizedHandle) {
const normalizedMatch = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
.unique();
if (normalizedMatch && !normalizedMatch.deletedAt && !normalizedMatch.deactivatedAt) {
return normalizedMatch;
}
}
const publisher = await getPublisherByHandle(ctx, normalizedHandle);
if (publisher?.kind === "user" && publisher.linkedUserId) {
const linkedUser = await ctx.db.get(publisher.linkedUserId);
if (linkedUser && !linkedUser.deletedAt && !linkedUser.deactivatedAt) {
return linkedUser;
}
}
// Migration bridge: older users may still have mixed-case handles without a
// personal publisher row yet. Fall back to a bounded scan so canonicalized
// lowercase profile URLs continue to resolve until the backfill finishes.
return await scanUsersByNormalizedHandle(ctx, normalizedHandle);
}
export const getByHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
return await getUserByHandleCaseAware(ctx, args.handle);
},
});
@@ -97,7 +161,19 @@ export const syncGitHubProfileInternal = internalMutation({
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user || user.deletedAt || user.deactivatedAt) return;
const canClaimNewHandle = await canUserClaimHandle(ctx, args.name, args.userId);
const rawUserHandle = user.handle?.trim();
const rawNewLogin = normalizeText(args.name);
const canonicalUserHandle = normalizeReservedHandle(user.handle);
const canonicalOldLogin = normalizeReservedHandle(user.name);
const canonicalNewLogin = normalizeReservedHandle(args.name);
const canClaimNewHandle = canonicalNewLogin
? await canUserClaimHandle(ctx, canonicalNewLogin, args.userId)
: false;
const canNormalizeExistingHandle =
rawUserHandle && canonicalUserHandle && rawUserHandle !== canonicalUserHandle
? await canUserClaimHandle(ctx, canonicalUserHandle, args.userId)
: false;
const updates: Partial<Doc<"users">> = { githubProfileSyncedAt: args.syncedAt };
let didChangeProfile = false;
@@ -107,19 +183,34 @@ export const syncGitHubProfileInternal = internalMutation({
didChangeProfile = true;
}
// Update handle if it was derived from the old username
if (user.handle === user.name && user.name !== args.name && canClaimNewHandle) {
updates.handle = args.name;
if (canNormalizeExistingHandle && canonicalUserHandle) {
updates.handle = canonicalUserHandle;
didChangeProfile = true;
}
// Update displayName if it was derived from the old username
// Update handle if it was derived from the old username.
if (
(user.displayName === user.name || user.displayName === user.handle) &&
user.name !== args.name &&
canonicalUserHandle &&
canonicalOldLogin &&
canonicalNewLogin &&
canonicalUserHandle === canonicalOldLogin &&
canonicalOldLogin !== canonicalNewLogin &&
canClaimNewHandle
) {
updates.displayName = args.name;
updates.handle = canonicalNewLogin;
didChangeProfile = true;
}
// Update displayName if it was derived from the old username and the login actually changed.
if (
rawNewLogin &&
canonicalOldLogin &&
canonicalNewLogin &&
canonicalOldLogin !== canonicalNewLogin &&
(user.displayName === user.name || user.displayName === user.handle) &&
canClaimNewHandle
) {
updates.displayName = rawNewLogin;
didChangeProfile = true;
}
@@ -185,8 +276,8 @@ export const ensure = mutation({
handler: ensureHandler,
});
function normalizeHandle(handle: string | undefined) {
const normalized = handle?.trim();
function normalizeText(value: string | undefined | null) {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
@@ -231,11 +322,29 @@ async function canUserClaimHandle(
return publisher.kind === "user" && publisher.linkedUserId === userId;
}
async function needsPersonalPublisherSync(
ctx: MutationCtx,
user: Doc<"users">,
canonicalHandle: string | undefined,
handleChanged: boolean,
) {
if (handleChanged) return true;
if (!canonicalHandle) return false;
if (!user.personalPublisherId) return true;
const publisher = await ctx.db.get(user.personalPublisherId);
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return true;
if (publisher.kind !== "user" || publisher.linkedUserId !== user._id) return true;
return publisher.handle !== canonicalHandle;
}
async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
const updates: Record<string, unknown> = {};
const existingHandle = normalizeHandle(user.handle);
const githubLogin = normalizeHandle(user.name);
const rawExistingHandle = normalizeText(user.handle);
const rawGithubLogin = normalizeText(user.name);
const existingHandle = normalizeReservedHandle(user.handle);
const githubLogin = normalizeReservedHandle(user.name);
const requestedHandle = deriveHandle({
existingHandle,
githubLogin,
@@ -245,25 +354,44 @@ async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
requestedHandle && (await canUserClaimHandle(ctx, requestedHandle, user._id))
? requestedHandle
: undefined;
if (
!derivedHandle &&
rawExistingHandle &&
existingHandle &&
rawExistingHandle !== existingHandle
) {
derivedHandle = (await canUserClaimHandle(ctx, existingHandle, user._id))
? existingHandle
: undefined;
}
if (!derivedHandle && !existingHandle) {
const emailFallback = !requestedHandle && user.email ? user.email.split("@")[0]?.trim() : user.email?.split("@")[0]?.trim();
const emailFallback = user.email?.split("@")[0]?.trim();
derivedHandle =
(emailFallback &&
emailFallback !== requestedHandle &&
(await resolveAvailableHandle(ctx, emailFallback, user._id))) ||
emailFallback !== requestedHandle &&
(await resolveAvailableHandle(ctx, emailFallback, user._id))) ||
(await resolveAvailableHandle(ctx, requestedHandle, user._id));
}
const baseHandle = derivedHandle ?? existingHandle;
if (derivedHandle && existingHandle !== derivedHandle) {
if (derivedHandle && rawExistingHandle !== derivedHandle) {
updates.handle = derivedHandle;
}
const displayName = normalizeHandle(user.displayName);
if (!displayName && baseHandle) {
updates.displayName = baseHandle;
} else if (derivedHandle && displayName === existingHandle) {
updates.displayName = derivedHandle;
const displayName = normalizeText(user.displayName);
const preferredDisplayName = rawGithubLogin ?? rawExistingHandle ?? baseHandle;
if (!displayName && preferredDisplayName) {
updates.displayName = preferredDisplayName;
} else if (
preferredDisplayName &&
derivedHandle &&
rawExistingHandle &&
displayName === rawExistingHandle &&
normalizeReservedHandle(rawExistingHandle) !== derivedHandle
) {
updates.displayName = preferredDisplayName;
}
if (!user.role) {
@@ -284,7 +412,9 @@ export async function ensureHandler(ctx: MutationCtx) {
updates.updatedAt = Date.now();
await ctx.db.patch(userId, updates);
}
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
const ensuredUser = hasUpdates
? ({ ...user, ...updates } as Doc<"users">)
: ((await ctx.db.get(userId)) ?? user);
await ensurePersonalPublisherForUser(ctx, ensuredUser);
return await ctx.db.get(userId);
}
@@ -396,11 +526,84 @@ function clampInt(value: number, min: number, max: number) {
export const getByHandle = query({
args: { handle: v.string() },
handler: async (ctx, args) => {
const user = await ctx.db
return toPublicUser(await getUserByHandleCaseAware(ctx, args.handle));
},
});
// Cursor-based admin backfill for legacy mixed-case user handles.
// Run one batch manually:
// bunx convex run users:backfillCanonicalHandlesInternal '{"batchSize":100}' --prod
// Or use the helper script:
// bun scripts/backfill-user-handles.ts --prod --batch-size 100
export const backfillCanonicalHandlesInternal = internalMutation({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
dryRun: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const batchSize = clampInt(
args.batchSize ?? DEFAULT_HANDLE_BACKFILL_BATCH_SIZE,
1,
MAX_HANDLE_BACKFILL_BATCH_SIZE,
);
const dryRun = args.dryRun ?? false;
const { page, isDone, continueCursor } = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", args.handle))
.unique();
return toPublicUser(user);
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
let normalizedUsers = 0;
let syncedPublishers = 0;
let skippedUsers = 0;
for (const user of page) {
if (user.deletedAt || user.deactivatedAt) continue;
const rawHandle = normalizeText(user.handle);
const canonicalHandle = normalizeReservedHandle(user.handle);
let nextUser = user;
let handleChanged = false;
if (rawHandle && canonicalHandle && rawHandle !== canonicalHandle) {
if (!(await canUserClaimHandle(ctx, canonicalHandle, user._id))) {
skippedUsers += 1;
continue;
}
handleChanged = true;
normalizedUsers += 1;
const updatedAt = Date.now();
nextUser = { ...user, handle: canonicalHandle, updatedAt };
if (!dryRun) {
await ctx.db.patch(user._id, {
handle: canonicalHandle,
updatedAt,
});
}
}
if (!(await needsPersonalPublisherSync(ctx, nextUser, canonicalHandle, handleChanged))) {
continue;
}
syncedPublishers += 1;
if (!dryRun) {
await ensurePersonalPublisherForUser(ctx, nextUser);
}
}
return {
ok: true as const,
scanned: page.length,
normalizedUsers,
syncedPublishers,
skippedUsers,
cursor: isDone ? null : continueCursor,
isDone,
dryRun,
};
},
});
@@ -822,7 +1025,8 @@ async function ensurePublisherHandleWithActor(
if (existing) {
const nextDisplayName =
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
args.displayName?.trim() &&
(!existing.displayName || existing.displayName === existing.handle)
? displayName
: existing.displayName;
await ctx.db.patch(existing._id, {
+1
View File
@@ -6,6 +6,7 @@
],
"type": "module",
"scripts": {
"backfill:user-handles": "bun scripts/backfill-user-handles.ts",
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
"check:peers": "bun scripts/check-peer-deps.ts",
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env bun
import { execFileSync } from "node:child_process";
type Options = {
batchSize: number;
pauseMs: number;
maxBatches: number | null;
dryRun: boolean;
prod: boolean;
deploymentName: string | null;
previewName: string | null;
envFile: string | null;
};
type BatchResult = {
ok: true;
scanned: number;
normalizedUsers: number;
syncedPublishers: number;
skippedUsers: number;
cursor: string | null;
isDone: boolean;
dryRun: boolean;
};
function printUsage() {
console.log(`Usage:
bun scripts/backfill-user-handles.ts --prod [--batch-size 100] [--pause-ms 250] [--max-batches 20] [--dry-run]
bun scripts/backfill-user-handles.ts --deployment-name <name> [--batch-size 100]
Options:
--batch-size <n> Users per batch. Default: 100
--pause-ms <n> Delay between batches in ms. Default: 250
--max-batches <n> Stop after n batches even if more remain
--dry-run Report what would change without writing
--prod Run against prod
--deployment-name <n> Run against a named deployment
--preview-name <n> Run against a preview deployment
--env-file <path> Custom env file for Convex CLI
--help Show this help
`);
}
function requireValue(args: string[], index: number, flag: string) {
const value = args[index + 1];
if (!value) {
throw new Error(`Missing value for ${flag}`);
}
return value;
}
function parsePositiveInt(value: string, flag: string) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${flag} must be a positive integer`);
}
return parsed;
}
function parseNonNegativeInt(value: string, flag: string) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`${flag} must be a non-negative integer`);
}
return parsed;
}
function parseArgs(argv: string[]): Options {
const options: Options = {
batchSize: 100,
pauseMs: 250,
maxBatches: null,
dryRun: false,
prod: false,
deploymentName: null,
previewName: null,
envFile: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--batch-size":
options.batchSize = parsePositiveInt(requireValue(argv, index, arg), arg);
index += 1;
break;
case "--pause-ms":
options.pauseMs = parseNonNegativeInt(requireValue(argv, index, arg), arg);
index += 1;
break;
case "--max-batches":
options.maxBatches = parsePositiveInt(requireValue(argv, index, arg), arg);
index += 1;
break;
case "--dry-run":
options.dryRun = true;
break;
case "--prod":
options.prod = true;
break;
case "--deployment-name":
options.deploymentName = requireValue(argv, index, arg);
index += 1;
break;
case "--preview-name":
options.previewName = requireValue(argv, index, arg);
index += 1;
break;
case "--env-file":
options.envFile = requireValue(argv, index, arg);
index += 1;
break;
case "--help":
case "-h":
printUsage();
process.exit(0);
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
const deploymentFlags = [
options.prod,
Boolean(options.deploymentName),
Boolean(options.previewName),
].filter(Boolean).length;
if (deploymentFlags !== 1) {
throw new Error(
"Choose exactly one deployment target: --prod, --deployment-name, or --preview-name",
);
}
return options;
}
function buildConvexArgs(options: Options, cursor: string | null) {
const payload = {
batchSize: options.batchSize,
...(cursor ? { cursor } : {}),
...(options.dryRun ? { dryRun: true } : {}),
};
const args = [
"convex",
"run",
"users:backfillCanonicalHandlesInternal",
JSON.stringify(payload),
"--codegen",
"disable",
"--typecheck",
"disable",
];
if (options.prod) {
args.push("--prod");
} else if (options.deploymentName) {
args.push("--deployment-name", options.deploymentName);
} else if (options.previewName) {
args.push("--preview-name", options.previewName);
}
if (options.envFile) {
args.push("--env-file", options.envFile);
}
return args;
}
function runBatch(options: Options, cursor: string | null): BatchResult {
try {
const output = execFileSync("bunx", buildConvexArgs(options, cursor), {
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
}).trim();
return JSON.parse(output) as BatchResult;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to run users:backfillCanonicalHandlesInternal. Deploy/push the new Convex function first.\n${message}`,
);
}
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function main() {
const options = parseArgs(process.argv.slice(2));
let cursor: string | null = null;
let batches = 0;
let totalScanned = 0;
let totalNormalized = 0;
let totalPublisherSyncs = 0;
let totalSkipped = 0;
console.log(
`[backfill-user-handles] starting${options.dryRun ? " (dry-run)" : ""} with batchSize=${options.batchSize}`,
);
while (true) {
if (options.maxBatches !== null && batches >= options.maxBatches) {
console.log(
`[backfill-user-handles] reached max batches (${options.maxBatches}), stopping early`,
);
break;
}
const result = runBatch(options, cursor);
batches += 1;
totalScanned += result.scanned;
totalNormalized += result.normalizedUsers;
totalPublisherSyncs += result.syncedPublishers;
totalSkipped += result.skippedUsers;
cursor = result.cursor;
console.log(
`[backfill-user-handles] batch=${batches} scanned=${result.scanned} normalized=${result.normalizedUsers} publishers=${result.syncedPublishers} skipped=${result.skippedUsers} done=${result.isDone}`,
);
if (result.isDone || result.cursor === null) {
break;
}
if (options.pauseMs > 0) {
await sleep(options.pauseMs);
}
}
console.log(
`[backfill-user-handles] complete batches=${batches} scanned=${totalScanned} normalized=${totalNormalized} publishers=${totalPublisherSyncs} skipped=${totalSkipped} remainingCursor=${cursor ?? "none"}`,
);
}
try {
await main();
} catch (error) {
console.error(
`[backfill-user-handles] ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
+7 -7
View File
@@ -18,16 +18,16 @@ export function UserBadge({
showName = false,
}: UserBadgeProps) {
const userName = user && "name" in user ? user.name?.trim() : undefined;
const displayName =
user?.displayName?.trim() || userName || null;
const displayName = user?.displayName?.trim() || userName || null;
const handle = user?.handle ?? fallbackHandle ?? null;
const hrefHandle = handle?.trim().toLowerCase() || null;
const href =
user?.handle && "kind" in user
hrefHandle && user?.handle && "kind" in user
? user.kind === "org"
? `/orgs/${encodeURIComponent(user.handle)}`
: `/u/${encodeURIComponent(user.handle)}`
: user?.handle
? `/u/${encodeURIComponent(user.handle)}`
? `/orgs/${encodeURIComponent(hrefHandle)}`
: `/u/${encodeURIComponent(hrefHandle)}`
: hrefHandle
? `/u/${encodeURIComponent(hrefHandle)}`
: null;
const label = handle ? `@${handle}` : "user";
const image = user?.image ?? null;
+1 -1
View File
@@ -6,7 +6,7 @@ export function buildSkillHref(
ownerId: Id<"users"> | Id<"publishers"> | null,
slug: string,
) {
const owner = ownerHandle?.trim() || (ownerId ? String(ownerId) : "unknown");
const owner = ownerHandle?.trim().toLowerCase() || (ownerId ? String(ownerId) : "unknown");
return `/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`;
}
+24 -10
View File
@@ -14,11 +14,11 @@ import {
} from "lucide-react";
import { useEffect, useState } from "react";
import semver from "semver";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import type { PublicSkill } from "../lib/publicUser";
import { api } from "../../convex/_generated/api";
import { formatCompactStat } from "../lib/numberFormat";
import { familyLabel } from "../lib/packageLabels";
import type { PublicSkill } from "../lib/publicUser";
const emptyPluginPublishSearch = {
ownerHandle: undefined,
@@ -104,7 +104,8 @@ function Dashboard() {
useEffect(() => {
if (selectedPublisherId) return;
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
const personal =
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
if (personal?.publisher._id) {
setSelectedPublisherId(personal.publisher._id);
}
@@ -121,7 +122,10 @@ function Dashboard() {
const skills = mySkills ?? [];
const packages = myPackages ?? [];
const ownerHandle =
selectedPublisher?.publisher.handle ?? me.handle ?? me.name ?? me.displayName ?? me._id;
selectedPublisher?.publisher.handle ??
me.handle?.trim().toLowerCase() ??
me.name?.trim().toLowerCase() ??
me._id;
return (
<main className="section">
@@ -177,9 +181,14 @@ function Dashboard() {
{skills.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
<strong>No skills yet.</strong> Publish your first skill to share it with the
community.
</div>
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
<Link
to="/publish-skill"
search={{ updateSlug: undefined }}
className="btn btn-primary"
>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
@@ -211,7 +220,8 @@ function Dashboard() {
{packages.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
<strong>No plugins yet.</strong> Publish your first plugin release to validate and
distribute it.
</div>
<Link
to="/publish-plugin"
@@ -264,7 +274,8 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.downloads)}
</span>
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
@@ -355,7 +366,9 @@ function PackageStatusTag({
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
const scanLabel = scanStatusLabel(pkg.scanStatus);
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
const sourceLabel = pkg.sourceRepo
?.replace(/^https?:\/\/github\.com\//, "")
.replace(/\.git$/, "");
const scanTone =
pkg.scanStatus === "pending"
? "pending"
@@ -400,7 +413,8 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
{formatCompactStat(pkg.stats.downloads)}
</span>
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
+4 -1
View File
@@ -199,7 +199,10 @@ export function ImportGitHub() {
});
const nextSlug = result.slug;
setStatus("Imported.");
const ownerParam = me?.handle ?? (me?._id ? String(me._id) : "unknown");
const ownerParam =
me?.handle?.trim().toLowerCase() ??
me?.name?.trim().toLowerCase() ??
(me?._id ? String(me._id) : "unknown");
await navigate({ to: "/$owner/$slug", params: { owner: ownerParam, slug: nextSlug } });
} catch (e) {
setError(getUserFacingConvexError(e, "Import failed"));
+8 -6
View File
@@ -8,10 +8,7 @@ import { useAction, useMutation, useQuery } from "convex/react";
import { useEffect, useMemo, useRef, useState } from "react";
import semver from "semver";
import { api } from "../../convex/_generated/api";
import {
MAX_PUBLISH_FILE_BYTES,
MAX_PUBLISH_TOTAL_BYTES,
} from "../../convex/lib/publishLimits";
import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits";
import { getSiteMode } from "../lib/site";
import { getPublicSlugCollision } from "../lib/slugCollision";
import { expandDroppedItems, expandFilesWithReport } from "../lib/uploadFiles";
@@ -194,7 +191,9 @@ export function Upload() {
useEffect(() => {
if (ownerHandle) return;
const personalPublisher = publisherMemberships?.find((entry) => entry.publisher.kind === "user");
const personalPublisher = publisherMemberships?.find(
(entry) => entry.publisher.kind === "user",
);
if (personalPublisher?.publisher.handle) {
setOwnerHandle(personalPublisher.publisher.handle);
}
@@ -419,7 +418,10 @@ export function Upload() {
setChangelogSource("user");
if (result) {
const ownerParam =
ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown");
ownerHandle ||
me?.handle?.trim().toLowerCase() ||
me?.name?.trim().toLowerCase() ||
(me?._id ? String(me._id) : "unknown");
void navigate({
to: isSoulMode ? "/souls/$slug" : "/$owner/$slug",
params: isSoulMode ? { slug: trimmedSlug } : { owner: ownerParam, slug: trimmedSlug },
+2 -1
View File
@@ -32,6 +32,7 @@ export type SkillSearchEntry = {
};
export function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) {
const owner = ownerHandle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
const owner =
ownerHandle?.trim().toLowerCase() || String(skill.ownerPublisherId ?? skill.ownerUserId);
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`;
}