From 764895a3b0a7b843ffe00794caf3c286dee12f56 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 20 Feb 2026 16:10:43 -0500 Subject: [PATCH] Fix modreport dates and recover corrupted timestamps from snowflake IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes three issues with the /modreport command: 1. **Date queries**: Changed from MIN/MAX aggregates to explicit ORDER BY + LIMIT queries for more reliable first/last report dates 2. **Formatting**: Changed channel and reason breakdowns from inline (·-separated) to newline-separated lists for better readability 3. **Staff breakdown**: Added detailed "Reported By" field showing which staff members reported the user and how many times **Migration fixes:** - Updated 20260218120000_fix_created_at_defaults.ts to recover timestamps from Discord snowflake IDs instead of destroying data with datetime('now') - Added 20260220130000_recover_dates_from_snowflakes.ts to recover timestamps already corrupted in production by the previous destructive migration Discord snowflake IDs encode creation time in the first 42 bits, allowing us to recover original timestamps: ((id >> 22) + 1420070400000) / 1000 This fixes the bug where "first reported" was showing today's date instead of the actual first report date due to migration-time data corruption. Co-Authored-By: Claude Opus 4.6 --- app/commands/modreport.ts | 25 +++++--- app/models/reportedMessages.ts | 50 +++++++++++++--- .../20260218120000_fix_created_at_defaults.ts | 43 ++++++++++---- ...220130000_recover_dates_from_snowflakes.ts | 57 +++++++++++++++++++ 4 files changed, 149 insertions(+), 26 deletions(-) create mode 100644 migrations/20260220130000_recover_dates_from_snowflakes.ts diff --git a/app/commands/modreport.ts b/app/commands/modreport.ts index 8347e251..6d0fee03 100644 --- a/app/commands/modreport.ts +++ b/app/commands/modreport.ts @@ -26,6 +26,7 @@ import { getChannelBreakdown, getMonthlyReportCounts, getRecentReportCount, + getStaffBreakdown, getUserReportSummary, type ReportReasons, } from "#~/models/reportedMessages"; @@ -125,6 +126,7 @@ export const Command = { recentActions, recency, channels, + staff, monthlyData, ] = yield* Effect.all([ getUserReportSummary(targetUser.id, guildId), @@ -134,6 +136,7 @@ export const Command = { getRecentModActions(targetUser.id, guildId), getRecentReportCount(targetUser.id, guildId), getChannelBreakdown(targetUser.id, guildId), + getStaffBreakdown(targetUser.id, guildId), getMonthlyReportCounts(targetUser.id, guildId, SPARKLINE_MONTHS), ]); @@ -183,12 +186,6 @@ export const Command = { if (sparkline) { descLines.push(`\`${sparkline}\` (last ${SPARKLINE_MONTHS} months)`); } - - if (summary.uniqueStaffCount > 0) { - descLines.push( - `Reported by ${summary.uniqueStaffCount} different staff member${summary.uniqueStaffCount === 1 ? "" : "s"}`, - ); - } } // Action count summary line @@ -230,7 +227,7 @@ export const Command = { (r) => `${ReadableReasons[r.reason as ReportReasons] ?? r.reason} ×${r.count}`, ) - .join(" · "); + .join("\n"); fields.push({ name: "Reasons", value: truncateMessage(reasonText, 1024), @@ -242,7 +239,7 @@ export const Command = { if (channels.length > 0) { const channelText = channels .map((c) => `<#${c.reported_channel_id}> (${Number(c.count)})`) - .join(" · "); + .join("\n"); fields.push({ name: "Top Channels", value: truncateMessage(channelText, 1024), @@ -250,6 +247,18 @@ export const Command = { }); } + // Staff reporter breakdown + if (staff.length > 0) { + const staffText = staff + .map((s) => `@${s.staff_username} (${Number(s.count)})`) + .join("\n"); + fields.push({ + name: "Reported By", + value: truncateMessage(staffText, 1024), + inline: true, + }); + } + // Mod action timeline if (recentActions.length > 0) { const timelineLines = recentActions.map((a) => { diff --git a/app/models/reportedMessages.ts b/app/models/reportedMessages.ts index b09e9c95..1cf07312 100644 --- a/app/models/reportedMessages.ts +++ b/app/models/reportedMessages.ts @@ -220,7 +220,8 @@ export const getUserReportSummary = (userId: string, guildId: string) => const [ stats, reasonBreakdown, - [timeRow], + [firstReportRow], + [lastReportRow], [anonRow], [peakDayRow], [staffRow], @@ -235,15 +236,24 @@ export const getUserReportSummary = (userId: string, guildId: string) => .where("deleted_at", "is", null) .groupBy("reason") .orderBy("count", "desc"), + // Get first report (earliest created_at) kysely .selectFrom("reported_messages") - .select((eb) => [ - eb.fn.min("created_at").as("firstReport"), - eb.fn.max("created_at").as("lastReport"), - ]) + .select("created_at") .where("reported_user_id", "=", userId) .where("guild_id", "=", guildId) - .where("deleted_at", "is", null), + .where("deleted_at", "is", null) + .orderBy("created_at", "asc") + .limit(1), + // Get last report (latest created_at) + kysely + .selectFrom("reported_messages") + .select("created_at") + .where("reported_user_id", "=", userId) + .where("guild_id", "=", guildId) + .where("deleted_at", "is", null) + .orderBy("created_at", "desc") + .limit(1), kysely .selectFrom("reported_messages") .select((eb) => eb.fn.count("id").as("count")) @@ -278,8 +288,8 @@ export const getUserReportSummary = (userId: string, guildId: string) => reason: r.reason, count: Number(r.count), })), - firstReport: timeRow?.firstReport as string | null, - lastReport: timeRow?.lastReport as string | null, + firstReport: firstReportRow?.created_at ?? null, + lastReport: lastReportRow?.created_at ?? null, anonymousCount: Number(anonRow?.count ?? 0), peakDayCount: Number(peakDayRow?.count ?? 0), uniqueStaffCount: Number(staffRow?.count ?? 0), @@ -394,6 +404,30 @@ export const getChannelBreakdown = ( }), ); +/** + * Get report counts grouped by staff member for a user in a guild. + */ +export const getStaffBreakdown = (userId: string, guildId: string, limit = 5) => + Effect.gen(function* () { + const kysely = yield* DatabaseService; + + return yield* kysely + .selectFrom("reported_messages") + .select(["staff_id", "staff_username"]) + .select((eb) => eb.fn.count("id").as("count")) + .where("reported_user_id", "=", userId) + .where("guild_id", "=", guildId) + .where("deleted_at", "is", null) + .where("staff_id", "is not", null) + .groupBy(["staff_id", "staff_username"]) + .orderBy("count", "desc") + .limit(limit); + }).pipe( + Effect.withSpan("getStaffBreakdown", { + attributes: { userId, guildId, limit }, + }), + ); + /** * Delete a report from the database. */ diff --git a/migrations/20260218120000_fix_created_at_defaults.ts b/migrations/20260218120000_fix_created_at_defaults.ts index 1e49ad43..caded483 100644 --- a/migrations/20260218120000_fix_created_at_defaults.ts +++ b/migrations/20260218120000_fix_created_at_defaults.ts @@ -4,21 +4,44 @@ import { sql, type Kysely } from "kysely"; * Fix rows where created_at was stored as the literal string 'CURRENT_TIMESTAMP' * instead of an actual timestamp, due to the column default being quoted. * + * For reported_messages, we can recover the original timestamps by extracting + * them from Discord snowflake IDs (log_message_id). + * + * For other tables without snowflake IDs, we use datetime('now') as a fallback. + * * The inserts now explicitly provide created_at, so the broken default no longer * matters for new rows. */ export async function up(db: Kysely): Promise { - await sql`UPDATE reported_messages SET created_at = datetime('now') WHERE created_at = 'CURRENT_TIMESTAMP'`.execute( - db, - ); - await sql`UPDATE user_threads SET created_at = datetime('now') WHERE created_at = 'CURRENT_TIMESTAMP'`.execute( - db, - ); - await sql`UPDATE guild_subscriptions SET created_at = datetime('now') WHERE created_at = 'CURRENT_TIMESTAMP'`.execute( - db, - ); + // Recover timestamps from Discord snowflake IDs for reported_messages + // Discord snowflake formula: ((id >> 22) + 1420070400000) / 1000 = unix timestamp + await sql` + UPDATE reported_messages + SET created_at = datetime( + (CAST(log_message_id AS INTEGER) >> 22) / 1000.0 + 1420070400, + 'unixepoch' + ) + WHERE created_at = 'CURRENT_TIMESTAMP' + `.execute(db); + + // For user_threads, try to recover from thread_id snowflake + await sql` + UPDATE user_threads + SET created_at = datetime( + (CAST(thread_id AS INTEGER) >> 22) / 1000.0 + 1420070400, + 'unixepoch' + ) + WHERE created_at = 'CURRENT_TIMESTAMP' + `.execute(db); + + // For guild_subscriptions, no snowflake available - use current time + await sql` + UPDATE guild_subscriptions + SET created_at = datetime('now') + WHERE created_at = 'CURRENT_TIMESTAMP' + `.execute(db); } export async function down(_db: Kysely): Promise { - // Not reversible — we can't recover the original (missing) timestamps + // Not reversible — we can't restore the broken 'CURRENT_TIMESTAMP' strings } diff --git a/migrations/20260220130000_recover_dates_from_snowflakes.ts b/migrations/20260220130000_recover_dates_from_snowflakes.ts new file mode 100644 index 00000000..638bf697 --- /dev/null +++ b/migrations/20260220130000_recover_dates_from_snowflakes.ts @@ -0,0 +1,57 @@ +import { sql, type Kysely } from "kysely"; + +/** + * Recover corrupted timestamps from Discord snowflake IDs. + * + * The 20260218120000_fix_created_at_defaults migration was destructive - it set + * all timestamps to datetime('now') instead of recovering them from snowflake IDs. + * + * This migration fixes that by extracting the original timestamps from Discord + * snowflake IDs, which encode the creation time in the first 42 bits. + * + * We only update rows that were likely corrupted by the previous migration + * (timestamps on or after 2026-02-18) to avoid touching any correct data. + */ +export async function up(db: Kysely): Promise { + // Recover timestamps from Discord snowflake IDs for reported_messages + // Discord snowflake formula: ((id >> 22) + 1420070400000) / 1000 = unix timestamp + // Only update rows that were corrupted by the previous migration (Feb 18-20, 2026) + await sql` + UPDATE reported_messages + SET created_at = datetime( + (CAST(log_message_id AS INTEGER) >> 22) / 1000.0 + 1420070400, + 'unixepoch' + ) + WHERE created_at >= '2026-02-18' + AND created_at <= '2026-02-21' + `.execute(db); + + // Recover timestamps from thread_id snowflake for user_threads + await sql` + UPDATE user_threads + SET created_at = datetime( + (CAST(thread_id AS INTEGER) >> 22) / 1000.0 + 1420070400, + 'unixepoch' + ) + WHERE created_at >= '2026-02-18' + AND created_at <= '2026-02-21' + `.execute(db); + + // For deletion_log_threads, recover from thread_id + await sql` + UPDATE deletion_log_threads + SET created_at = datetime( + (CAST(thread_id AS INTEGER) >> 22) / 1000.0 + 1420070400, + 'unixepoch' + ) + WHERE created_at >= '2026-02-18' + AND created_at <= '2026-02-21' + `.execute(db); + + // guild_subscriptions has no snowflake IDs - cannot recover + // Leave those timestamps as-is +} + +export async function down(_db: Kysely): Promise { + // Not reversible - we can't restore the corrupted timestamps +}