mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* fix: show honest canonical trending states * ci: guard CLAW-602 permanent Test deploy * fix: fail closed when trending discovery is unavailable * fix: preserve trending rows on pagination errors * feat: build native rolling trending feed * fix: decouple native trending from skills.sh * test: cover native trending rollout independence
152 lines
5.0 KiB
TypeScript
152 lines
5.0 KiB
TypeScript
import { v } from "convex/values";
|
|
import type { Doc } from "./_generated/dataModel";
|
|
import type { MutationCtx } from "./_generated/server";
|
|
import { internalMutation, mutation, query } from "./functions";
|
|
import { getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
|
import { toPublicSkill } from "./lib/public";
|
|
import { bumpLiveHourlySkillStats, ensureHourlyStatsState } from "./lib/skillHourlyStats";
|
|
import { applySkillStatDeltas } from "./lib/skillStats";
|
|
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
|
|
|
async function applyStarDelta(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
skill: Doc<"skills">,
|
|
delta: 1 | -1,
|
|
occurredAt: number,
|
|
hourlyState?: { activeGeneration: number },
|
|
) {
|
|
const patch = applySkillStatDeltas(skill, { stars: delta });
|
|
const nextSkill = { ...skill, ...patch };
|
|
await ctx.db.patch(skill._id, patch);
|
|
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
|
if (hourlyState) {
|
|
await bumpLiveHourlySkillStats(
|
|
ctx,
|
|
{
|
|
skillId: skill._id,
|
|
occurredAt,
|
|
bookmarks: delta,
|
|
},
|
|
{ state: hourlyState },
|
|
);
|
|
}
|
|
}
|
|
|
|
export const isStarred = query({
|
|
args: { skillId: v.id("skills") },
|
|
handler: async (ctx, args) => {
|
|
const userId = await getOptionalActiveAuthUserId(ctx);
|
|
if (!userId) return false;
|
|
const existing = await ctx.db
|
|
.query("stars")
|
|
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", userId))
|
|
.unique();
|
|
return Boolean(existing);
|
|
},
|
|
});
|
|
|
|
export const toggle = mutation({
|
|
args: { skillId: v.id("skills") },
|
|
handler: async (ctx, args) => {
|
|
const { userId } = await requireUser(ctx);
|
|
const skill = await ctx.db.get(args.skillId);
|
|
if (!skill) throw new Error("Skill not found");
|
|
|
|
const existing = await ctx.db
|
|
.query("stars")
|
|
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", userId))
|
|
.unique();
|
|
|
|
if (existing) {
|
|
await ctx.db.delete(existing._id);
|
|
const hourlyState =
|
|
existing.hourlyStatsRecordedAt === undefined
|
|
? undefined
|
|
: await ensureHourlyStatsState(ctx);
|
|
await applyStarDelta(ctx, skill, -1, existing.createdAt, hourlyState);
|
|
return { starred: false };
|
|
}
|
|
|
|
if (skill.softDeletedAt) throw new Error("Skill not found");
|
|
|
|
const hourlyState = await ensureHourlyStatsState(ctx);
|
|
const createdAt = Date.now();
|
|
await ctx.db.insert("stars", {
|
|
skillId: args.skillId,
|
|
userId,
|
|
createdAt,
|
|
hourlyStatsRecordedAt: createdAt,
|
|
});
|
|
|
|
await applyStarDelta(ctx, skill, 1, createdAt, hourlyState);
|
|
|
|
return { starred: true };
|
|
},
|
|
});
|
|
|
|
export const listByUser = query({
|
|
args: { userId: v.id("users"), limit: v.optional(v.number()) },
|
|
handler: async (ctx, args) => {
|
|
const limit = args.limit ?? 50;
|
|
const stars = await ctx.db
|
|
.query("stars")
|
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
.order("desc")
|
|
.take(limit);
|
|
const skills: NonNullable<ReturnType<typeof toPublicSkill>>[] = [];
|
|
for (const star of stars) {
|
|
const skill = await ctx.db.get(star.skillId);
|
|
const publicSkill = toPublicSkill(skill);
|
|
if (!publicSkill) continue;
|
|
skills.push(publicSkill);
|
|
}
|
|
return skills;
|
|
},
|
|
});
|
|
|
|
export const addStarInternal = internalMutation({
|
|
args: { userId: v.id("users"), skillId: v.id("skills") },
|
|
handler: async (ctx, args) => {
|
|
const skill = await ctx.db.get(args.skillId);
|
|
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
|
const existing = await ctx.db
|
|
.query("stars")
|
|
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
|
|
.unique();
|
|
if (existing) return { ok: true as const, starred: true, alreadyStarred: true };
|
|
|
|
const hourlyState = await ensureHourlyStatsState(ctx);
|
|
const createdAt = Date.now();
|
|
await ctx.db.insert("stars", {
|
|
skillId: args.skillId,
|
|
userId: args.userId,
|
|
createdAt,
|
|
hourlyStatsRecordedAt: createdAt,
|
|
});
|
|
|
|
await applyStarDelta(ctx, skill, 1, createdAt, hourlyState);
|
|
|
|
return { ok: true as const, starred: true, alreadyStarred: false };
|
|
},
|
|
});
|
|
|
|
export const removeStarInternal = internalMutation({
|
|
args: { userId: v.id("users"), skillId: v.id("skills") },
|
|
handler: async (ctx, args) => {
|
|
const skill = await ctx.db.get(args.skillId);
|
|
if (!skill) throw new Error("Skill not found");
|
|
const existing = await ctx.db
|
|
.query("stars")
|
|
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
|
|
.unique();
|
|
if (!existing) return { ok: true as const, unstarred: false, alreadyUnstarred: true };
|
|
|
|
await ctx.db.delete(existing._id);
|
|
const hourlyState =
|
|
existing.hourlyStatsRecordedAt === undefined ? undefined : await ensureHourlyStatsState(ctx);
|
|
await applyStarDelta(ctx, skill, -1, existing.createdAt, hourlyState);
|
|
|
|
return { ok: true as const, unstarred: true, alreadyUnstarred: false };
|
|
},
|
|
});
|