Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions app/commands/modreport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getChannelBreakdown,
getMonthlyReportCounts,
getRecentReportCount,
getStaffBreakdown,
getUserReportSummary,
type ReportReasons,
} from "#~/models/reportedMessages";
Expand Down Expand Up @@ -125,6 +126,7 @@ export const Command = {
recentActions,
recency,
channels,
staff,
monthlyData,
] = yield* Effect.all([
getUserReportSummary(targetUser.id, guildId),
Expand All @@ -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),
]);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -242,14 +239,26 @@ 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),
inline: true,
});
}

// 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) => {
Expand Down
50 changes: 42 additions & 8 deletions app/models/reportedMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ export const getUserReportSummary = (userId: string, guildId: string) =>
const [
stats,
reasonBreakdown,
[timeRow],
[firstReportRow],
[lastReportRow],
[anonRow],
[peakDayRow],
[staffRow],
Expand All @@ -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"))
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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.
*/
Expand Down
43 changes: 33 additions & 10 deletions migrations/20260218120000_fix_created_at_defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>): Promise<void> {
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<any>): Promise<void> {
// Not reversible — we can't recover the original (missing) timestamps
// Not reversible — we can't restore the broken 'CURRENT_TIMESTAMP' strings
}
57 changes: 57 additions & 0 deletions migrations/20260220130000_recover_dates_from_snowflakes.ts
Original file line number Diff line number Diff line change
@@ -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<any>): Promise<void> {
// 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<any>): Promise<void> {
// Not reversible - we can't restore the corrupted timestamps
}