This commit is contained in:
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user