Compare commits

..
Author SHA1 Message Date
Nimrod Gutman ba699a114a fix(skill-detail): stop owner canonical redirect loop 2026-03-28 20:43:55 +03:00
7 changed files with 34 additions and 370 deletions
-30
View File
@@ -97,36 +97,6 @@ export async function getPublisherByHandle(
}
}
export async function getUserByHandleOrPersonalPublisher(
ctx: DbCtx,
handle: string | undefined | null,
) {
const normalized = normalizePublisherHandle(handle);
if (!normalized) return null;
const user = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", normalized))
.unique();
if (user) return user;
const publisher = await getPublisherByHandle(ctx, normalized);
if (!publisher || !isPublisherActive(publisher) || publisher.kind !== "user" || !publisher.linkedUserId) {
return null;
}
return await ctx.db.get(publisher.linkedUserId);
}
export async function getActiveUserByHandleOrPersonalPublisher(
ctx: DbCtx,
handle: string | undefined | null,
) {
const user = await getUserByHandleOrPersonalPublisher(ctx, handle);
if (!user || user.deletedAt || user.deactivatedAt) return null;
return user;
}
export async function getPersonalPublisherForUser(
ctx: DbCtx,
userId: Id<"users">,
-183
View File
@@ -169,189 +169,6 @@ describe("publishers membership controls", () => {
),
).rejects.toThrow("Publisher must have at least one owner");
});
it("adds a member when the requested handle resolves via a personal publisher", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const publisherMembers: Array<Record<string, unknown>> = [
{
_id: "publisherMembers:owner",
publisherId: "publishers:org",
userId: "users:owner",
role: "owner",
},
];
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
if (table === "publisherMembers") {
const row = { _id: "publisherMembers:new", ...value };
publisherMembers.push(row);
return row._id;
}
if (table === "auditLogs") return "auditLogs:1";
if (table === "publishers") return "publishers:jaredforreal";
throw new Error(`unexpected insert ${table}`);
});
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") return { _id: id };
if (id === "users:jared") {
return {
_id: id,
_creationTime: 1,
handle: undefined,
name: "JaredForReal",
displayName: "Jared",
trustedPublisher: false,
createdAt: 1,
updatedAt: 1,
};
}
if (id === "publishers:org") {
return {
_id: id,
kind: "org",
handle: "zai-org",
displayName: "ZAI Org",
};
}
if (id === "publishers:jaredforreal") {
return {
_id: id,
_creationTime: 1,
kind: "user",
handle: "jaredforreal",
displayName: "Jared",
linkedUserId: "users:jared",
trustedPublisher: false,
createdAt: 1,
updatedAt: 1,
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
if (indexName !== "by_publisher_user") {
throw new Error(`unexpected index ${indexName}`);
}
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 () =>
publisherMembers.find(
(member) => member.publisherId === publisherId && member.userId === userId,
) ?? null,
),
};
}),
};
}
if (table === "users") {
return {
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
if (indexName !== "handle") {
throw new Error(`unexpected index ${indexName}`);
}
let handle = "";
const q = {
eq: (field: string, value: string) => {
if (field === "handle") handle = value;
return q;
},
};
builder?.(q);
return {
unique: vi.fn(async () => {
if (handle === "owner") return { _id: "users:owner", handle: "owner" };
return null;
}),
};
}),
};
}
if (table === "publishers") {
return {
withIndex: vi.fn((indexName: 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);
return {
unique: vi.fn(async () => {
if (indexName === "by_handle" && handle === "jaredforreal") {
return {
_id: "publishers:jaredforreal",
_creationTime: 1,
kind: "user",
handle: "jaredforreal",
displayName: "Jared",
linkedUserId: "users:jared",
trustedPublisher: false,
createdAt: 1,
updatedAt: 1,
};
}
if (indexName === "by_linked_user" && linkedUserId === "users:jared") {
return {
_id: "publishers:jaredforreal",
_creationTime: 1,
kind: "user",
handle: "jaredforreal",
displayName: "Jared",
linkedUserId: "users:jared",
trustedPublisher: false,
createdAt: 1,
updatedAt: 1,
};
}
return null;
}),
};
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
insert,
patch: vi.fn(),
delete: vi.fn(),
replace: vi.fn(),
normalizeId: vi.fn(),
},
};
await expect(
addMemberHandler(
ctx as never,
{ publisherId: "publishers:org", userHandle: "jaredforreal", role: "admin" } as never,
),
).resolves.toEqual({ ok: true });
expect(insert).toHaveBeenCalledWith(
"publisherMembers",
expect.objectContaining({
publisherId: "publishers:org",
userId: "users:jared",
role: "admin",
}),
);
});
});
describe("publisher bootstrap", () => {
+5 -3
View File
@@ -6,7 +6,6 @@ import { internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, requireUser } from "./lib/access";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getPublisherMembership,
getPersonalPublisherForUserOrFallback,
@@ -585,8 +584,11 @@ export const addMember = mutation({
}
const handle = normalizePublisherHandle(args.userHandle);
if (!handle) throw new ConvexError("User handle is required");
const targetUser = await getActiveUserByHandleOrPersonalPublisher(ctx, handle);
if (!targetUser) {
const targetUser = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", handle))
.unique();
if (!targetUser || targetUser.deletedAt || targetUser.deactivatedAt) {
throw new ConvexError(`User "@${handle}" not found`);
}
await ensurePersonalPublisherForUser(ctx, targetUser);
+1 -96
View File
@@ -61,7 +61,7 @@ describe("skillTransfers", () => {
if (table === "users") {
return {
withIndex: () => ({
unique: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
first: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
}),
};
}
@@ -102,101 +102,6 @@ describe("skillTransfers", () => {
);
});
it("requestTransferInternal resolves recipient via personal publisher handle", async () => {
const insert = vi.fn(async (table: string) => {
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
return "auditLogs:1";
});
const result = (await requestTransferInternalHandler(
{
db: {
normalizeId: vi.fn(),
get: vi.fn(async (id: string) => {
if (id === "users:1") return { _id: "users:1", handle: "owner" };
if (id === "users:2") {
return {
_id: "users:2",
handle: undefined,
name: "Alice",
displayName: "Alice",
};
}
if (id === "skills:1") {
return {
_id: "skills:1",
slug: "demo",
displayName: "Demo",
ownerUserId: "users:1",
};
}
if (id === "publishers:alice") {
return {
_id: "publishers:alice",
kind: "user",
handle: "alice",
displayName: "Alice",
linkedUserId: "users:2",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "users") {
return {
withIndex: () => ({
unique: async () => null,
}),
};
}
if (table === "publishers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publishers:alice",
kind: "user",
handle: "alice",
displayName: "Alice",
linkedUserId: "users:2",
}),
}),
};
}
if (table === "skillOwnershipTransfers") {
return {
withIndex: () => ({
collect: async () => [],
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
patch: vi.fn(async () => {}),
insert,
},
} as never,
{
actorUserId: "users:1",
skillId: "skills:1",
toUserHandle: "@alice",
} as never,
)) as { ok: boolean; transferId: string };
expect(result).toEqual(
expect.objectContaining({
ok: true,
transferId: "skillOwnershipTransfers:new",
toUserHandle: "alice",
}),
);
expect(insert).toHaveBeenCalledWith(
"skillOwnershipTransfers",
expect.objectContaining({
toUserId: "users:2",
}),
);
});
it("acceptTransferInternal updates skill and alias ownership to the recipient publisher", async () => {
const patch = vi.fn(async () => {});
const insert = vi.fn(async () => "auditLogs:1");
+6 -6
View File
@@ -1,10 +1,7 @@
import { v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, internalQuery } from "./functions";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
type TransferDoc = Doc<"skillOwnershipTransfers">;
@@ -115,8 +112,11 @@ export const requestTransferInternal = internalMutation({
const toHandle = normalizeHandle(args.toUserHandle);
if (!toHandle) throw new Error("toUserHandle required");
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
if (!toUser) throw new Error("User not found");
const toUser = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", toHandle))
.first();
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) throw new Error("User not found");
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
-48
View File
@@ -616,54 +616,6 @@ describe("users.getByHandle", () => {
bio: "Profile",
});
});
it("does not resolve a deleted personal publisher handle", async () => {
const userUnique = vi.fn(async () => null);
const publisherUnique = vi.fn(async () => ({
_id: "publishers:jaredforreal",
kind: "user",
handle: "jaredforreal",
linkedUserId: "users:owner",
deletedAt: 1_700_000_000_000,
displayName: "Jared",
}));
const get = vi.fn(async () => {
throw new Error("linked user should not be loaded for inactive publishers");
});
const result = await getByHandleHandler(
{
db: {
query: vi.fn((table: string) => {
if (table === "users") {
return {
withIndex: (name: string) => {
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
return { unique: userUnique };
},
};
}
if (table === "publishers") {
return {
withIndex: (name: string) => {
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
return { unique: publisherUnique };
},
};
}
throw new Error(`Unexpected table ${table}`);
}),
get,
},
} as never,
{ handle: "jaredforreal" },
);
expect(userUnique).toHaveBeenCalledOnce();
expect(publisherUnique).toHaveBeenCalledOnce();
expect(get).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe("users.syncGitHubProfileInternal", () => {
+22 -4
View File
@@ -8,9 +8,8 @@ import { assertAdmin, assertModerator, requireUser } from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getUserByHandleOrPersonalPublisher,
normalizePublisherHandle,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
@@ -41,7 +40,12 @@ export const getByIdInternal = internalQuery({
export const getByHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
return await getUserByHandleOrPersonalPublisher(ctx, args.handle);
const normalizedHandle = normalizePublisherHandle(args.handle);
if (!normalizedHandle) return null;
return await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
.unique();
},
});
@@ -396,7 +400,21 @@ function clampInt(value: number, min: number, max: number) {
export const getByHandle = query({
args: { handle: v.string() },
handler: async (ctx, args) => {
return toPublicUser(await getActiveUserByHandleOrPersonalPublisher(ctx, args.handle));
const normalizedHandle = normalizePublisherHandle(args.handle);
if (!normalizedHandle) return null;
const user = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
.unique();
if (user) return toPublicUser(user);
const publisher = await getPublisherByHandle(ctx, normalizedHandle);
if (!publisher || publisher.kind !== "user" || !publisher.linkedUserId) return null;
const linkedUser = await ctx.db.get(publisher.linkedUserId);
if (!linkedUser) return null;
return toPublicUser(linkedUser);
},
});