Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE "audit_log" (
"id" SERIAL NOT NULL,
"guild_id" VARCHAR(19) NOT NULL,
"user_id" VARCHAR(19) NOT NULL,
"action" VARCHAR(64) NOT NULL,
"section" VARCHAR(64) NOT NULL,
"changes" JSON NOT NULL DEFAULT '[]',
"created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "IDX_audit_log_guild_created" ON "audit_log"("guild_id", "created_at" DESC);
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "guilds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
19 changes: 19 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,12 @@ model Guild {
channelsIgnoreVoiceActivity String[] @default([]) @map("channels.ignore.voice-activity") @db.VarChar(19)
eventsTimeout Boolean @default(false) @map("events.timeout")

auditLogs AuditLog[]

@@map("guilds")
}


model Migration {
id Int @id(map: "PK_Migrations") @default(autoincrement())
timestamp BigInt
Expand Down Expand Up @@ -219,3 +222,19 @@ model User {

@@map("user")
}

model AuditLog {
id Int @id @default(autoincrement())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @map("guild_id") @db.VarChar(19)
userId String @map("user_id") @db.VarChar(19)
action String @db.VarChar(64)
section String @db.VarChar(64)
/// [AuditLogChanges]
changes Json @default("[]") @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6)

@@index([guildId, createdAt(sort: Desc)], map: "IDX_audit_log_guild_created")
@@map("audit_log")
}

8 changes: 8 additions & 0 deletions src/lib/database/settings/context/SettingsContext.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AdderManager } from '#lib/database/settings/structures/AdderManager';
import { PermissionNodeManager } from '#lib/database/settings/structures/PermissionNodeManager';
import { AuditLogManager } from '#lib/database/settings/structures/AuditLogManager';
import type { ReadonlyGuildData } from '#lib/database/settings/types';
import { create } from '#utils/Security/RegexCreator';
import { RateLimitManager } from '@sapphire/ratelimits';
Expand All @@ -8,12 +9,14 @@ import { isNullish, isNullishOrEmpty } from '@sapphire/utilities';
export class SettingsContext {
readonly #adders: AdderManager;
readonly #permissionNodes: PermissionNodeManager;
#auditLog: AuditLogManager;
#wordFilterRegExp: RegExp | null;
#noMentionSpam: RateLimitManager;

public constructor(settings: ReadonlyGuildData) {
this.#adders = new AdderManager(settings);
this.#permissionNodes = new PermissionNodeManager(settings);
this.#auditLog = new AuditLogManager(settings);
this.#wordFilterRegExp = isNullishOrEmpty(settings.selfmodFilterRaw) ? null : new RegExp(create(settings.selfmodFilterRaw), 'gi');
this.#noMentionSpam = new RateLimitManager(settings.noMentionSpamTimePeriod * 1000, settings.noMentionSpamMentionsAllowed);
}
Expand All @@ -26,6 +29,10 @@ export class SettingsContext {
return this.#permissionNodes;
}

public get auditLog() {
return this.#auditLog;
}

public get wordFilterRegExp() {
return this.#wordFilterRegExp;
}
Expand All @@ -36,6 +43,7 @@ export class SettingsContext {

public update(settings: ReadonlyGuildData, data: Partial<ReadonlyGuildData>) {
this.#adders.onPatch(settings);
this.#auditLog.onPatch(settings);

if (!isNullish(data.permissionsRoles) || !isNullish(data.permissionsUsers)) {
this.#permissionNodes.refresh(settings);
Expand Down
4 changes: 4 additions & 0 deletions src/lib/database/settings/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export function readSettingsCached(guild: GuildResolvable): ReadonlyGuildData |
return cache.get(resolveGuildId(guild)) ?? null;
}

export function readSettingsAuditLog(settings: ReadonlyGuildData) {
return getSettingsContext(settings).auditLog;
}
Comment on lines +64 to +66
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readSettingsAuditLog() returns the cached SettingsContext.auditLog instance keyed by settings.id. This makes it unsuitable for callers that expect a snapshot-based manager (e.g., passing a cloned settings object), because it will still return the shared per-guild instance whose internal #settings reference is updated on every settings patch. Consider returning a new AuditLogManager(settings) here (or adding a separate helper like createSettingsAuditLogSnapshot for non-cached instances) so audit logs can reliably compare against the provided settings object.

Copilot uses AI. Check for mistakes.

export async function writeSettings(
guild: GuildResolvable,
data: Partial<ReadonlyGuildData> | ((settings: ReadonlyGuildData) => Awaitable<Partial<ReadonlyGuildData>>)
Expand Down
1 change: 1 addition & 0 deletions src/lib/database/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from '#lib/database/settings/schema/SchemaGroup';
export * from '#lib/database/settings/schema/SchemaKey';
export * from '#lib/database/settings/structures/AdderManager';
export * from '#lib/database/settings/structures/PermissionNodeManager';
export * from '#lib/database/settings/structures/AuditLogManager';
export * from '#lib/database/settings/structures/Serializer';
export * from '#lib/database/settings/structures/SerializerStore';
export * from '#lib/database/settings/types';
Expand Down
101 changes: 101 additions & 0 deletions src/lib/database/settings/structures/AuditLogManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { AuditLogChange, ReadonlyGuildData } from '#lib/database/settings/types';
import { container } from '@sapphire/framework';

export class AuditLogManager {
#guildId: string;
#settings: ReadonlyGuildData;

public constructor(settings: ReadonlyGuildData) {
this.#guildId = settings.id;
this.#settings = settings;
}

public onPatch(settings: ReadonlyGuildData): void {
this.#guildId = settings.id;
this.#settings = settings;
}

public update(userId: string, newData: Record<string, unknown>): Promise<void> {
const changedKeys = Object.keys(newData);
const changes = this.#buildChanges(newData);

return this.write(userId, {
action: 'settings.update',
section: AuditLogManager.deriveSection(changedKeys),
changes
});
}

public add(userId: string, key: string, value: unknown): Promise<void> {
return this.write(userId, {
action: 'settings.add',
section: AuditLogManager.deriveSection([key]),
changes: [{ key, newValue: AuditLogManager.serializeValue(value) }]
});
}

public remove(userId: string, key: string, value: unknown): Promise<void> {
return this.write(userId, {
action: 'settings.remove',
section: AuditLogManager.deriveSection([key]),
changes: [{ key, oldValue: AuditLogManager.serializeValue(value) }]
});
}

public async write(userId: string, params: { action: string; section: string; changes: AuditLogChange[] }): Promise<void> {
await container.prisma.auditLog.create({
data: {
guildId: this.#guildId,
userId,
action: params.action,
section: params.section,
changes: JSON.parse(JSON.stringify(params.changes))
}
});
}

#buildChanges(newData: Record<string, unknown>): AuditLogChange[] {
return Object.keys(newData).map((key) => ({
key,
oldValue: AuditLogManager.serializeValue((this.#settings as Record<string, unknown>)[key]),
newValue: AuditLogManager.serializeValue(newData[key])
}));
}

private static deriveSection(keys: string[]): string {
const counts = new Map<string, number>();
for (const key of keys) {
const section = AuditLogManager.classifyKey(key);
counts.set(section, (counts.get(section) ?? 0) + 1);
}

if (counts.size === 0) return 'general';
if (counts.size === 1) return counts.keys().next().value!;

let best = 'general';
let max = 0;
for (const [section, count] of counts) {
if (count > max) {
max = count;
best = section;
}
}
return best;
}

private static classifyKey(key: string): string {
if (key.startsWith('permissions')) return 'permissions';
if (key.startsWith('selfmod') || key.startsWith('noMentionSpam')) return 'moderation';
if (key.startsWith('channels')) return 'channels';
if (key.startsWith('roles')) return 'roles';
if (key.startsWith('events')) return 'events';
if (key.startsWith('messages')) return 'messages';
if (key.startsWith('disabled')) return 'commands';
return 'general';
}

private static serializeValue(value: unknown): unknown {
if (typeof value === 'bigint') return Number(value);
return value;
}
}
6 changes: 6 additions & 0 deletions src/lib/database/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import type { Snowflake } from 'discord.js';

export type { Guild as GuildData, Moderation as ModerationData, User as UserData } from '#generated/prisma';

export interface AuditLogChange {
key: string;
oldValue?: unknown;
newValue?: unknown;
}

export interface PermissionsNode {
allow: readonly Snowflake[];
deny: readonly Snowflake[];
Expand Down
4 changes: 3 additions & 1 deletion src/lib/types/Augments.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import type {
ReactionRole,
SerializerStore,
StickyRole,
UniqueRoleSet
UniqueRoleSet,
AuditLogChange
} from '#lib/database';
import type { GuildMemberFetchQueue } from '#lib/discord/GuildMemberFetchQueue';
import type { WorkerManager } from '#lib/moderation/workers/WorkerManager';
Expand All @@ -34,6 +35,7 @@ declare global {
export type StickyRoleEntries = StickyRole[];
export type ReactionRoleEntries = ReactionRole[];
export type UniqueRoleSetEntries = UniqueRoleSet[];
export type AuditLogChanges = AuditLogChange[];
}
}

Expand Down
37 changes: 37 additions & 0 deletions src/routes/guilds/[guild]/audit-logs.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { authenticated, canManage, ratelimit } from '#lib/api/utils';
import { seconds } from '#utils/common';
import { container } from '@sapphire/framework';
import { HttpCodes, Route } from '@sapphire/plugin-api';
Comment on lines +1 to +4
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route uses the global container import instead of the this.container instance property used by the other guild routes in this folder. For consistency (and to make testing/mocking easier), prefer this.container.client / this.container.prisma here as in channels.get.ts, roles.get.ts, etc.

Copilot uses AI. Check for mistakes.

export class UserRoute extends Route {
@authenticated()
@ratelimit(seconds(10), 5, true)
public async run(request: Route.Request, response: Route.Response) {
const guildId = request.params.guild;

const guild = container.client.guilds.cache.get(guildId);
if (!guild) return response.error(HttpCodes.BadRequest);

const member = await guild.members.fetch(request.auth!.id).catch(() => null);
if (!member) return response.error(HttpCodes.BadRequest);

if (!(await canManage(guild, member))) return response.error(HttpCodes.Forbidden);

const take = Math.min(Number(request.query.take) || 50, 100);
const skip = Math.max(Number(request.query.skip) || 0, 0);

const [results, total] = await Promise.all([
container.prisma.auditLog.findMany({
where: { guildId },
orderBy: { createdAt: 'desc' },
take,
skip
}),
container.prisma.auditLog.count({
where: { guildId }
})
]);

return response.status(HttpCodes.OK).json({ entries: results, total });
}
}
11 changes: 10 additions & 1 deletion src/routes/guilds/[guild]/settings.patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { authenticated, canManage, ratelimit } from '#lib/api/utils';
import {
getConfigurableKeys,
isSchemaKey,
readSettingsAuditLog,
serializeSettings,
writeSettingsTransaction,
type GuildDataValue,
Expand Down Expand Up @@ -37,7 +38,15 @@ export class UserRoute extends Route {
try {
using trx = await writeSettingsTransaction(guild);
const data = await this.validateAll(trx.settings, guild, entries);
await trx.write(Object.fromEntries(data)).submit();
const settingsData = Object.fromEntries(data);

// Capture current settings for audit log before mutation
const auditLog = readSettingsAuditLog(structuredClone(trx.settings));

await trx.write(settingsData).submit();

// Fire-and-forget audit log write
auditLog.update(request.auth!.id, settingsData).catch(() => null);
Comment on lines +43 to +49
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

structuredClone(trx.settings) does not actually create an independent audit-log snapshot here. readSettingsAuditLog() goes through getSettingsContext(settings) which is cached by settings.id, so it returns the existing per-guild AuditLogManager. After trx.write(...).submit(), updateSettingsContext() calls SettingsContext.update() which onPatch()es the same manager with the now-mutated settings object, causing oldValue to reflect the new settings (and often match newValue). To preserve pre-mutation values, create a fresh AuditLogManager instance from the cloned settings (bypassing the context cache), or change readSettingsAuditLog to support returning a non-cached manager for snapshots.

Copilot uses AI. Check for mistakes.

return this.sendSettings(response, trx.settings);
} catch (errors) {
Expand Down
Loading
Loading