import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import { getRolesForUser } from "./roles"; import { getPublicStorageUrl } from "./storageUrl"; export const list = query({ args: {}, returns: v.any(), handler: async (ctx) => { const emojis = await ctx.db.query("customEmojis").collect(); const results = await Promise.all( emojis.map(async (emoji) => { const src = await getPublicStorageUrl(ctx, emoji.storageId); const user = await ctx.db.get(emoji.uploadedBy); let avatarUrl: string | null = null; if (user?.avatarStorageId) { avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId); } return { _id: emoji._id, name: emoji.name, src, createdAt: emoji.createdAt, animated: emoji.animated ?? false, uploadedById: emoji.uploadedBy, uploadedByUsername: user?.username || "Unknown", uploadedByDisplayName: user?.displayName || null, uploadedByAvatarUrl: avatarUrl, }; }) ); return results.filter((e) => e.src !== null); }, }); export const upload = mutation({ args: { userId: v.id("userProfiles"), name: v.string(), storageId: v.id("_storage"), animated: v.optional(v.boolean()), }, returns: v.null(), handler: async (ctx, args) => { // Permission check const roles = await getRolesForUser(ctx, args.userId); const canManage = roles.some( (role) => (role.permissions as Record)?.["manage_channels"] ); if (!canManage) { throw new Error("You don't have permission to manage emojis"); } // Validate name format const name = args.name.trim(); if (!/^[a-zA-Z0-9_]+$/.test(name)) { throw new Error("Emoji name can only contain letters, numbers, and underscores"); } if (name.length < 2 || name.length > 32) { throw new Error("Emoji name must be between 2 and 32 characters"); } // Check for duplicate name among custom emojis const existing = await ctx.db .query("customEmojis") .withIndex("by_name", (q) => q.eq("name", name)) .first(); if (existing) { throw new Error(`A custom emoji named "${name}" already exists`); } await ctx.db.insert("customEmojis", { name, storageId: args.storageId, uploadedBy: args.userId, animated: args.animated ?? false, createdAt: Date.now(), }); return null; }, }); /** * Rename a custom emoji in place. Enforces the same name validation * as `upload` and rejects collisions with other existing emojis so * two rows can't end up sharing a shortcode. */ export const rename = mutation({ args: { userId: v.id("userProfiles"), emojiId: v.id("customEmojis"), name: v.string(), }, returns: v.null(), handler: async (ctx, args) => { const roles = await getRolesForUser(ctx, args.userId); const canManage = roles.some( (role) => (role.permissions as Record)?.["manage_channels"] ); if (!canManage) { throw new Error("You don't have permission to manage emojis"); } const emoji = await ctx.db.get(args.emojiId); if (!emoji) throw new Error("Emoji not found"); const name = args.name.trim(); if (!/^[a-zA-Z0-9_]+$/.test(name)) { throw new Error("Emoji name can only contain letters, numbers, and underscores"); } if (name.length < 2 || name.length > 32) { throw new Error("Emoji name must be between 2 and 32 characters"); } if (name !== emoji.name) { const clash = await ctx.db .query("customEmojis") .withIndex("by_name", (q) => q.eq("name", name)) .first(); if (clash && clash._id !== args.emojiId) { throw new Error(`A custom emoji named "${name}" already exists`); } } await ctx.db.patch(args.emojiId, { name }); return null; }, }); export const remove = mutation({ args: { userId: v.id("userProfiles"), emojiId: v.id("customEmojis"), }, returns: v.null(), handler: async (ctx, args) => { // Permission check const roles = await getRolesForUser(ctx, args.userId); const canManage = roles.some( (role) => (role.permissions as Record)?.["manage_channels"] ); if (!canManage) { throw new Error("You don't have permission to manage emojis"); } const emoji = await ctx.db.get(args.emojiId); if (!emoji) throw new Error("Emoji not found"); await ctx.storage.delete(emoji.storageId); await ctx.db.delete(args.emojiId); return null; }, });