bump
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-04-20 17:09:04 -05:00
parent 82f8a12e27
commit b83360db35
48 changed files with 6079 additions and 49 deletions

View File

@@ -1,18 +1,27 @@
import { query, internalMutation } from "./_generated/server";
import { query, mutation, internalMutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles";
import { isBanned } from "./bans";
import { AUDIT_ACTIONS, logAudit } from "./audit";
const DEFAULT_ROLE_COLOR = "#99aab5";
async function enrichMessage(ctx: any, msg: any, userId?: any) {
const sender = await ctx.db.get(msg.senderId);
// Real users use `avatarStorageId` (Convex storage). Ghost users
// (placeholder authors for imported Discord messages) don't have
// a storage blob — they carry the original Discord CDN URL in
// `ghostAvatarUrl` and the client renders it directly. If the
// CDN link expires the UI will fall back to initials, which is
// acceptable for historical content.
let avatarUrl: string | null = null;
if (sender?.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, sender.avatarStorageId);
} else if (sender?.isGhost && sender?.ghostAvatarUrl) {
avatarUrl = sender.ghostAvatarUrl as string;
}
// Highest-position role with a non-default colour — mirrors how
@@ -137,10 +146,18 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
replyToNonce = repliedMsg.nonce;
if (repliedSender?.avatarStorageId) {
replyToAvatarUrl = await getPublicStorageUrl(ctx, repliedSender.avatarStorageId);
} else if (repliedSender?.isGhost && repliedSender?.ghostAvatarUrl) {
replyToAvatarUrl = repliedSender.ghostAvatarUrl as string;
}
}
}
// Imported messages preserve the original Discord timestamp in
// `importedCreatedAt` so they slot into the channel history at
// the right spot. `created_at` still serialises as ISO for client
// compatibility — the client sorts by it directly.
const effectiveCreatedAt = msg.importedCreatedAt ?? msg._creationTime;
return {
id: msg._id,
channel_id: msg.channelId,
@@ -149,7 +166,7 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
nonce: msg.nonce,
signature: msg.signature,
key_version: msg.keyVersion,
created_at: new Date(msg._creationTime).toISOString(),
created_at: new Date(effectiveCreatedAt).toISOString(),
username: sender?.username || "Unknown",
displayName: sender?.displayName || null,
public_signing_key: sender?.publicSigningKey || "",
@@ -164,6 +181,8 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
replyToAvatarUrl,
editedAt: msg.editedAt || null,
pinned: msg.pinned || false,
isImported: msg.isImported ?? false,
importedCreatedAt: msg.importedCreatedAt ?? null,
};
}
@@ -537,3 +556,116 @@ export const removeInternal = internalMutation({
return null;
},
});
/**
* Owner-only: wipe every message (and its reactions) across every
* channel. Designed for a fresh-start reset — the Danger Zone UI
* double-confirms before calling this.
*
* Returns the remaining message count so the client can loop if a
* single batch isn't enough. `batchSize` caps per-call work to stay
* inside Convex's default read/write limits; default is 1000 which
* comfortably covers a small server in one shot.
*/
export const purgeAllMessages = mutation({
args: {
actorId: v.id("userProfiles"),
batchSize: v.optional(v.number()),
},
returns: v.object({
deletedMessages: v.number(),
deletedReactions: v.number(),
deletedPolls: v.number(),
deletedPollVotes: v.number(),
deletedPollReactions: v.number(),
remaining: v.number(),
}),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.actorId);
if (!user) throw new Error("User not found.");
const roles = await getRolesForUser(ctx, args.actorId);
const isOwner = user.isAdmin || roles.some((r) => r.name === "Owner");
if (!isOwner) {
throw new Error("Only the Owner can clear all messages.");
}
const batch = Math.max(1, Math.min(args.batchSize ?? 1000, 2000));
// 1) Messages + their reactions.
const messages = await ctx.db.query("messages").take(batch);
let deletedMessages = 0;
let deletedReactions = 0;
for (const m of messages) {
const reactions = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", m._id))
.collect();
for (const r of reactions) {
await ctx.db.delete(r._id);
deletedReactions += 1;
}
await ctx.db.delete(m._id);
deletedMessages += 1;
}
// 2) Polls + their votes + poll-level emoji reactions. Runs in the
// same batch so a single "Clear all messages" pass wipes everything
// a user can see in chat, not just plain text messages.
const polls = await ctx.db.query("polls").take(batch);
let deletedPolls = 0;
let deletedPollVotes = 0;
let deletedPollReactions = 0;
for (const p of polls) {
const votes = await ctx.db
.query("pollVotes")
.withIndex("by_poll", (q) => q.eq("pollId", p._id))
.collect();
for (const v of votes) {
await ctx.db.delete(v._id);
deletedPollVotes += 1;
}
const preactions = await ctx.db
.query("pollReactions")
.withIndex("by_poll", (q) => q.eq("pollId", p._id))
.collect();
for (const r of preactions) {
await ctx.db.delete(r._id);
deletedPollReactions += 1;
}
await ctx.db.delete(p._id);
deletedPolls += 1;
}
// Remaining count — either table still has rows means the client
// should call again. We peek one past the batch size so `remaining`
// is positive whenever there's *anything* left.
const leftoverMessages = await ctx.db.query("messages").take(1);
const leftoverPolls = await ctx.db.query("polls").take(1);
const remaining = leftoverMessages.length + leftoverPolls.length;
if (deletedMessages > 0 || deletedPolls > 0) {
await logAudit(ctx, {
actorId: args.actorId,
action: AUDIT_ACTIONS.MESSAGES_PURGE_ALL,
targetType: "server",
metadata: {
deletedMessages,
deletedReactions,
deletedPolls,
deletedPollVotes,
deletedPollReactions,
remainingAfterBatch: remaining,
},
});
}
return {
deletedMessages,
deletedReactions,
deletedPolls,
deletedPollVotes,
deletedPollReactions,
remaining,
};
},
});