diff --git a/apps/community/src/server/moderation-actions.test.ts b/apps/community/src/server/moderation-actions.test.ts index 1140471d..0f0efd15 100644 --- a/apps/community/src/server/moderation-actions.test.ts +++ b/apps/community/src/server/moderation-actions.test.ts @@ -31,7 +31,20 @@ vi.mock('next/navigation', () => ({ const actorRef: { current: Actor | null } = { current: null } vi.mock('./context', () => ({ getActor: async () => actorRef.current })) -const { moderateQueueAction } = await import('./moderation-actions') +const adminCalls: Array<{ action: string; detail: unknown }> = [] +vi.mock('./admin', () => ({ + recordAdminAction: async (input: { action: string; detail?: unknown }) => { + adminCalls.push({ action: input.action, detail: input.detail }) + }, +})) + +const avatarLocks: Array<{ userId: number; locked: boolean; reason: string }> = [] +const avatarServiceRef: { current: unknown } = { current: null } +vi.mock('./avatars', () => ({ avatarService: () => avatarServiceRef.current })) + +const { moderateQueueAction, setAvatarLockAction, setSignatureLockAction } = await import( + './moderation-actions' +) const { EMPTY_STATE } = await import('./auth-form-state') const { SEED_BOARD, SEED_FORUM, SEED_GROUP } = await import('./seed-board') @@ -103,9 +116,24 @@ const SELECTION: Array<[string, string]> = [ ['item', 'post:20'], ] +const signatureLocks: Array<{ userId: number; locked: boolean; reason: string | null }> = [] + +async function warnerActor(userId: number): Promise { + const actor = await actorFor(SEED_GROUP.superModerators, userId) + return { ...actor, global: { ...actor.global, canWarnUsers: true } } +} + beforeEach(async () => { queue = new FakeQueue() actorRef.current = await actorFor(SEED_GROUP.superModerators, 2) + adminCalls.length = 0 + signatureLocks.length = 0 + avatarLocks.length = 0 + avatarServiceRef.current = { + setLock: async (input: { userId: number; locked: boolean; reason: string }) => { + avatarLocks.push(input) + }, + } installContainer() }) @@ -211,3 +239,89 @@ describe('moderateQueueAction', () => { expect(queue.applied).toHaveLength(0) }) }) + +describe('locking what a member displays', () => { + beforeEach(async () => { + actorRef.current = await warnerActor(2) + installContainer({ + signatures: { + setLocked: async (input: { + userId: number + locked: boolean + reason: string | null + }) => { + signatureLocks.push(input) + }, + }, + }) + }) + + it('records a signature lock against the member it was applied to', async () => { + await redirectOf( + setSignatureLockAction( + EMPTY_STATE, + form([['userId', '7'], ['locked', '1'], ['reason', 'advertising']]), + ), + ) + + expect(signatureLocks[0]).toMatchObject({ userId: 7, locked: true }) + expect(adminCalls).toEqual([{ action: 'signature.lock', detail: { userId: 7 } }]) + }) + + it('records the release as its own action, not as the lock again', async () => { + await redirectOf( + setSignatureLockAction(EMPTY_STATE, form([['userId', '7'], ['locked', '0']])), + ) + + expect(adminCalls).toEqual([{ action: 'signature.unlock', detail: { userId: 7 } }]) + }) + + it('records an avatar lock and its release the same way', async () => { + await redirectOf( + setAvatarLockAction( + EMPTY_STATE, + form([['userId', '7'], ['locked', '1'], ['reason', 'obscene']]), + ), + ) + await redirectOf( + setAvatarLockAction(EMPTY_STATE, form([['userId', '7'], ['locked', '0']])), + ) + + expect(avatarLocks).toHaveLength(2) + expect(adminCalls.map((call) => call.action)).toEqual(['avatar.lock', 'avatar.unlock']) + }) + + it('keeps the reason the member is shown out of the log', async () => { + await redirectOf( + setSignatureLockAction( + EMPTY_STATE, + form([['userId', '7'], ['locked', '1'], ['reason', 'advertising a rival board']]), + ), + ) + + expect(JSON.stringify(adminCalls)).not.toContain('rival board') + }) + + it('logs nothing when the lock was refused', async () => { + actorRef.current = await actorFor(SEED_GROUP.registered, 3) + + const state = await setSignatureLockAction( + EMPTY_STATE, + form([['userId', '7'], ['locked', '1'], ['reason', 'advertising']]), + ) + + expect(state.error).toBeTruthy() + expect(signatureLocks).toEqual([]) + expect(adminCalls).toEqual([]) + }) + + it('logs nothing when the lock was rejected for having no reason', async () => { + const state = await setSignatureLockAction( + EMPTY_STATE, + form([['userId', '7'], ['locked', '1']]), + ) + + expect(state.error).toMatch(/say why/i) + expect(adminCalls).toEqual([]) + }) +}) diff --git a/apps/community/src/server/moderation-actions.ts b/apps/community/src/server/moderation-actions.ts index 2bb41aa1..6b27074e 100644 --- a/apps/community/src/server/moderation-actions.ts +++ b/apps/community/src/server/moderation-actions.ts @@ -5,6 +5,7 @@ import { redirect } from 'next/navigation' import { ForbiddenError, ValidationError } from '@meith/core' import { ModerationQueue, parseSelection } from '@meith/moderation' +import { recordAdminAction } from './admin' import { avatarService } from './avatars' import { getActor } from './context' import { getContainer } from './container' @@ -106,6 +107,10 @@ export async function setSignatureLockAction( } await store.setLocked({ userId, locked, reason: locked ? reason : null }) + await recordAdminAction({ + action: locked ? 'signature.lock' : 'signature.unlock', + detail: { userId }, + }) } catch (err) { return toFormState(err) } @@ -136,10 +141,11 @@ export async function setAvatarLockAction( throw new ValidationError('No such member.') } - await service.setLock({ - userId, - locked: form.get('locked') === '1', - reason: text('reason'), + const locked = form.get('locked') === '1' + await service.setLock({ userId, locked, reason: text('reason') }) + await recordAdminAction({ + action: locked ? 'avatar.lock' : 'avatar.unlock', + detail: { userId }, }) } catch (err) { return toFormState(err) diff --git a/apps/community/src/server/user-admin-actions.test.ts b/apps/community/src/server/user-admin-actions.test.ts index 53efc6ad..f7223774 100644 --- a/apps/community/src/server/user-admin-actions.test.ts +++ b/apps/community/src/server/user-admin-actions.test.ts @@ -503,6 +503,19 @@ describe('mass mail', () => { expect(state.values?.queued).toBe('7') }) + it('records every further batch, so a campaign is not one row and then silence', async () => { + claimResult.current = { + recipients: [{ userId: 3, email: 'c@example.test', username: 'cal' }], + finished: false, + } + + await continueMassMailAction({}, form({ massMailId: '55' })) + + expect(adminCalls).toEqual([ + { action: 'user.mass_mail_continued', detail: { massMailId: 55, sent: 1, queued: 7 } }, + ]) + }) + it('refuses to continue a campaign that is not one', async () => { const state = await continueMassMailAction({}, form({ massMailId: 'latest' })) expect(state.error).toBeDefined() diff --git a/apps/community/src/server/user-admin-actions.ts b/apps/community/src/server/user-admin-actions.ts index 4c4203b3..19d2c7eb 100644 --- a/apps/community/src/server/user-admin-actions.ts +++ b/apps/community/src/server/user-admin-actions.ts @@ -273,7 +273,7 @@ export async function startMassMailAction( detail: { massMailId, targetGroupId }, }) - return queueMassMailBatch(bulk, massMailId) + return (await queueMassMailBatch(bulk, massMailId)).state } catch (err) { return toFormState(err) } @@ -291,7 +291,13 @@ export async function continueMassMailAction( throw new ValidationError('No such message.') } - return queueMassMailBatch(requireUserBulk(), massMailId) + const batch = await queueMassMailBatch(requireUserBulk(), massMailId) + await recordAdminAction({ + action: 'user.mass_mail_continued', + detail: { massMailId, sent: batch.sent, queued: batch.queued }, + }) + + return batch.state } catch (err) { return toFormState(err) } @@ -300,7 +306,7 @@ export async function continueMassMailAction( async function queueMassMailBatch( bulk: ReturnType, massMailId: number, -): Promise { +): Promise<{ state: FormState; sent: number; queued: number }> { const chunk = await bulk.claimMassMailChunk(massMailId, MASS_MAIL_CHUNK) for (const recipient of chunk.recipients) { @@ -314,8 +320,12 @@ async function queueMassMailBatch( const total = (await bulk.readMassMail(massMailId))?.queuedCount ?? 0 return { - notice: chunk.finished ? 'sent' : 'more', - values: { massMailId: String(massMailId), queued: String(total) }, + state: { + notice: chunk.finished ? 'sent' : 'more', + values: { massMailId: String(massMailId), queued: String(total) }, + }, + sent: chunk.recipients.length, + queued: total, } } diff --git a/docs/mybb-parity.md b/docs/mybb-parity.md index 6fc46711..7c0c7308 100644 --- a/docs/mybb-parity.md +++ b/docs/mybb-parity.md @@ -568,6 +568,49 @@ action into a moderator-visible disclosure the day somebody forgets to update it, whereas an allow-list turns a new moderation action into a missing row somebody notices. +### Everything that changes something is logged, and nothing that does not + +**MyBB:** the moderator log records what the moderation tools do. Handling a +report, deleting one post from the thread it is in, or editing somebody else's +post leaves nothing behind in it. + +**Here:** every path that changes content, a member's presentation or a report +writes a row, whichever screen it was reached from. Closing a report writes +`report.resolve` or `report.reject`; deleting or restoring a single post from +the postbit writes `post.delete` or `post.restore`; editing a post somebody else +wrote writes `post.edit`; locking a signature or an avatar writes +`signature.lock`/`signature.unlock` or `avatar.lock`/`avatar.unlock`. Copying a +thread already wrote `thread.copy` and now appears in the ModCP, because the +action is in the allow-list the reader filters by. Every further batch of a mass +mail writes `user.mass_mail_continued`, so a campaign is not one row followed by +silence for the next several thousand recipients. + +**Why:** `/admin/log` says it holds "every administrative and moderation action" +and the ModCP offers itself as the record of a forum. A log that is only mostly +complete is worse than one that admits a boundary, because the missing row reads +as "it did not happen". The rows that concern a forum carry the same +`forumIds`/`forumId` scope keys as every other moderation row, so they reach the +moderators of the forum they happened in and no one else. + +Where the change is a database write, the row is written in the transaction that +makes it — a moderation that rolls back leaves no row claiming it happened. The +two that are not database writes of their own, the signature and avatar locks, +go through the same helper as the rest of the control panel and so also record +the address the moderator acted from. + +**The boundary is authorship, and it is deliberate.** A member deleting or +editing their own post writes nothing: it is not moderation, and logging it +would bury the moderation in it. The row is written when the actor is not the +post's author, which is the same question the postbit asks to decide whether it +is showing a moderator's button. Taking a report or putting it back is not +logged either — it moves nothing, and the report's own timeline already shows +who holds it. + +**Cost.** A forum whose moderators edit heavily has a longer log than MyBB's, +and every entry names a post rather than only a thread. The moderator log has no +retention policy, so `admin_log` grows with moderation rather than with +administration alone. + ### Every log row names the forums it concerns, at the moment it is written **MyBB:** the moderator log carries an `fid` column, and the ModCP scopes the diff --git a/docs/operating.md b/docs/operating.md index 8f7c1e5c..7d3a277f 100644 --- a/docs/operating.md +++ b/docs/operating.md @@ -1340,6 +1340,22 @@ recorded, so every action the board has ever logged can be filtered on. The dropdown is what the log contains, not a fixed list — an action nobody has performed yet is not offered, and appears the first time it happens. +### What reaches the admin log + +`/admin/log` is the whole table. Administrative actions and moderation actions +share it, so the control panel's log is the superset and the ModCP's is the same +rows filtered to moderation actions in the forums a moderator holds — see +[Everything that changes something is logged](./mybb-parity.md#everything-that-changes-something-is-logged-and-nothing-that-does-not) +for what qualifies. + +Two things are on the screen but not in the table, and knowing which is which +saves an investigation. A member editing or deleting **their own** post writes +no row; the row appears when somebody else does it to them. And a report's +assignment — a moderator taking it, or putting it back — is on the report's own +timeline rather than in the log, because it changes nothing about the board. +Everything else a moderator or an administrator does leaves a row, including +each 500-recipient batch of a mass mail. + ## Migrations Migrations are **forward-only**. There is no down migration and there will not be diff --git a/packages/db/src/modcp-repo.test.ts b/packages/db/src/modcp-repo.test.ts index 9ffa84ec..e8b8914b 100644 --- a/packages/db/src/modcp-repo.test.ts +++ b/packages/db/src/modcp-repo.test.ts @@ -244,6 +244,64 @@ describe('the moderator log', () => { ).toHaveLength(1) }) + it('shows a copy to the moderators of the forum it came from and the one it landed in', async () => { + await logRow( + 'thread.copy', + { + threadId: 7, + fromForumId: THEIRS, + toForumId: MINE, + forumIds: [THEIRS, MINE], + newThreadId: 9, + posts: 2, + }, + OTHER_MOD, + ) + + expect( + (await repo.log({ forumIds: [MINE], actorUserId: MOD, limit: 10 })).entries, + ).toHaveLength(1) + expect( + (await repo.log({ forumIds: [THEIRS], actorUserId: IVAN, limit: 10 })).entries, + ).toHaveLength(1) + }) + + it('shows a closed report to everyone who moderates the forum it was filed in', async () => { + await logRow('report.resolve', { reportId: 3, forumId: MINE, forumIds: [MINE] }, OTHER_MOD) + + const entry = (await repo.log({ forumIds: [MINE], actorUserId: MOD, limit: 10 })) + .entries[0]! + expect(entry.action).toBe('report.resolve') + expect(entry.forumTitle).toBe('Mine') + expect(entry.detail).toContainEqual({ label: 'Report', value: '3' }) + }) + + it('shows a single-post deletion to the forum the post was in', async () => { + await logRow( + 'post.delete', + { postId: 11, threadId: 7, forumId: MINE, forumIds: [MINE] }, + OTHER_MOD, + ) + + expect( + (await repo.log({ forumIds: [MINE], actorUserId: MOD, limit: 10 })).entries, + ).toHaveLength(1) + expect( + (await repo.log({ forumIds: [THEIRS], actorUserId: IVAN, limit: 10 })).entries, + ).toEqual([]) + }) + + it('shows a signature lock to the moderator who set it and to nobody else', async () => { + await logRow('signature.lock', { userId: IVAN }, OTHER_MOD) + + expect((await repo.log({ forumIds: [MINE], actorUserId: MOD, limit: 10 })).entries).toEqual( + [], + ) + const own = await repo.log({ forumIds: [], actorUserId: OTHER_MOD, limit: 10 }) + expect(own.entries[0]).toMatchObject({ action: 'signature.lock' }) + expect(own.entries[0]!.detail).toContainEqual({ label: 'Member', value: String(IVAN) }) + }) + it('shows a forum-less entry only to the moderator who wrote it', async () => { await logRow('warning.issue', { userId: IVAN, points: 2 }, OTHER_MOD) diff --git a/packages/db/src/post-writes.test.ts b/packages/db/src/post-writes.test.ts index 14d61923..88f7af84 100644 --- a/packages/db/src/post-writes.test.ts +++ b/packages/db/src/post-writes.test.ts @@ -23,6 +23,7 @@ const CATEGORY = 1 const FORUM = 4 const CHILD = 5 const AUTHOR = 1 +const MOD = 2 const AT = new Date('2026-07-30T12:00:00Z') @@ -38,6 +39,7 @@ afterAll(async () => { }) beforeEach(async () => { + await db.execute(sql`delete from admin_log`) await db.execute(sql`delete from post_revisions`) await db.execute(sql`delete from thread_subscriptions`) await db.execute(sql`delete from content_counter_rollups`) @@ -47,16 +49,21 @@ beforeEach(async () => { await db.execute(sql`delete from forums`) await db.execute(sql`delete from users`) - await db.insert(users).values({ - id: AUTHOR, - username: 'ada', - usernameLower: 'ada', - email: 'ada@example.test', - emailLower: 'ada@example.test', - passwordHash: 'x', - passwordAlgo: 'argon2id', - primaryGroupId: 2, - }) + await db.insert(users).values( + [ + [AUTHOR, 'ada'], + [MOD, 'mod'], + ].map(([id, name]) => ({ + id: id as number, + username: name as string, + usernameLower: name as string, + email: `${String(name)}@example.test`, + emailLower: `${String(name)}@example.test`, + passwordHash: 'x', + passwordAlgo: 'argon2id', + primaryGroupId: 2, + })), + ) await db.insert(forums).values([ { id: CATEGORY, type: 'category', title: 'Cat', slug: 'cat', path: '1', depth: 0 }, { id: FORUM, title: 'General', slug: 'general', path: '1.4', depth: 1, parentId: CATEGORY }, @@ -441,6 +448,144 @@ describe('applyVisibility', () => { }) }) +describe('the moderator log a single-post write leaves', () => { + async function logRows(): Promise< + Array<{ user_id: number; action: string; detail: Record }> + > { + return resultRows( + await db.execute(sql`select user_id, action, detail from admin_log order by id`), + ) as Array<{ user_id: number; action: string; detail: Record }> + } + + const visibility = ( + postId: number, + threadId: number, + from: 'visible' | 'deleted', + to: 'visible' | 'deleted', + actedByUserId: number, + ) => ({ + postId, + threadId, + forumId: FORUM, + authorUserId: AUTHOR, + isFirstPost: false, + from, + to, + actedByUserId, + at: new Date('2026-07-30T13:00:00Z'), + }) + + const edit = (postId: number, threadId: number, editedByUserId: number) => ({ + postId, + threadId, + forumId: FORUM, + authorUserId: AUTHOR, + isFirstPost: false, + message: 'a **revised** body', + reason: 'off topic', + editedByUserId, + editedAt: new Date('2026-07-30T13:00:00Z'), + previousMessage: 'reply 0', + previousSubject: null, + revision: 1, + fromVisibility: 'visible' as const, + toVisibility: 'visible' as const, + }) + + it('records a moderator removing somebody else"s post, with the forum it was in', async () => { + const { threadId, postIds } = await seedThread() + + await repo.applyVisibility(visibility(postIds[1]!, threadId, 'visible', 'deleted', MOD)) + + const rows = await logRows() + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ user_id: MOD, action: 'post.delete' }) + expect(rows[0]!.detail).toEqual({ + postId: postIds[1], + threadId, + forumId: FORUM, + forumIds: [FORUM], + }) + }) + + it('records the restore as its own action rather than the deletion again', async () => { + const { threadId, postIds } = await seedThread() + + await repo.applyVisibility(visibility(postIds[1]!, threadId, 'visible', 'deleted', MOD)) + await repo.applyVisibility(visibility(postIds[1]!, threadId, 'deleted', 'visible', MOD)) + + expect((await logRows()).map((row) => row.action)).toEqual([ + 'post.delete', + 'post.restore', + ]) + }) + + it('says nothing when a member removes their own post', async () => { + const { threadId, postIds } = await seedThread() + + await repo.applyVisibility(visibility(postIds[1]!, threadId, 'visible', 'deleted', AUTHOR)) + + expect(await logRows()).toEqual([]) + }) + + it('writes no row for a delete that changed nothing', async () => { + const { threadId, postIds } = await seedThread() + + await repo.applyVisibility(visibility(postIds[1]!, threadId, 'visible', 'deleted', MOD)) + await repo.applyVisibility(visibility(postIds[1]!, threadId, 'visible', 'deleted', MOD)) + + expect(await logRows()).toHaveLength(1) + }) + + it('records a moderator editing a post they did not write', async () => { + const { threadId, postIds } = await seedThread() + + await repo.applyEdit(edit(postIds[1]!, threadId, MOD)) + + const rows = await logRows() + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ user_id: MOD, action: 'post.edit' }) + expect(rows[0]!.detail).toEqual({ + postId: postIds[1], + threadId, + forumId: FORUM, + forumIds: [FORUM], + }) + }) + + it('keeps the edit reason and the new body out of the log', async () => { + const { threadId, postIds } = await seedThread() + + await repo.applyEdit(edit(postIds[1]!, threadId, MOD)) + + const written = JSON.stringify(await logRows()) + expect(written).not.toContain('off topic') + expect(written).not.toContain('revised') + }) + + it('says nothing when the author edits their own post', async () => { + const { threadId, postIds } = await seedThread() + + await repo.applyEdit(edit(postIds[1]!, threadId, AUTHOR)) + + expect(await logRows()).toEqual([]) + }) + + it('records a moderator acting on a post whose author has no account', async () => { + const { threadId, postIds } = await seedThread() + await db.execute(sql` + update posts set author_user_id = null where id = ${postIds[1]!} + `) + + await repo.applyVisibility({ + ...visibility(postIds[1]!, threadId, 'visible', 'deleted', MOD), + authorUserId: null, + }) + + expect((await logRows())[0]).toMatchObject({ action: 'post.delete' }) + }) +}) + describe('applyAncestorVisibilityChange', () => { it('takes a deleted post off its ancestors', async () => { const { threadId, postIds } = await seedThread() diff --git a/packages/db/src/post-writes.ts b/packages/db/src/post-writes.ts index f29661cf..474abf67 100644 --- a/packages/db/src/post-writes.ts +++ b/packages/db/src/post-writes.ts @@ -11,6 +11,7 @@ import type { import type { Database } from './client' import { resultRows } from './result-rows' import { SEARCH_DOCUMENT_VERSION, indexedSubjectSql, searchVectorSql } from './search-repo' +import { logModeratorAction } from './thread-counters' import { readBoardVocabulary } from './vocabulary-repo' import { applyVisibilityChangeCounters } from './visibility-counters' @@ -18,6 +19,23 @@ function isCounted(visibility: string): boolean { return visibility === 'visible' } +function actedOnAnother(actorUserId: number, authorUserId: number | null): boolean { + return authorUserId === null || actorUserId !== authorUserId +} + +function scopedDetail(record: { + readonly postId: number + readonly threadId: number + readonly forumId: number +}): Record { + return { + postId: record.postId, + threadId: record.threadId, + forumId: record.forumId, + forumIds: [record.forumId], + } +} + export class PostgresPostWriteRepository implements PostWriteRepository { constructor(private readonly db: Database) {} @@ -141,6 +159,16 @@ export class PostgresPostWriteRepository implements PostWriteRepository { delta: delta as 1 | -1, }) } + + if (actedOnAnother(record.editedByUserId, record.authorUserId)) { + await logModeratorAction( + tx, + 'post.edit', + record.editedByUserId, + scopedDetail(record), + record.editedAt, + ) + } }) } @@ -165,6 +193,16 @@ export class PostgresPostWriteRepository implements PostWriteRepository { }) } + if (actedOnAnother(record.actedByUserId, record.authorUserId)) { + await logModeratorAction( + tx, + record.to === 'deleted' ? 'post.delete' : 'post.restore', + record.actedByUserId, + scopedDetail(record), + record.at, + ) + } + return true }) } diff --git a/packages/db/src/report-repo.test.ts b/packages/db/src/report-repo.test.ts index 0d1b9449..5d1d348a 100644 --- a/packages/db/src/report-repo.test.ts +++ b/packages/db/src/report-repo.test.ts @@ -3,7 +3,9 @@ import { sql } from 'drizzle-orm' import { ReportService } from '@meith/moderation' +import { PostgresAdminLogRepository } from './admin-session-repo' import type { Database } from './client' +import { PostgresModCpRepository } from './modcp-repo' import { createTestDb, type TestDb } from './pglite.fixture' import { PostgresReportRepository } from './report-repo' import { resultRows } from './result-rows' @@ -35,6 +37,7 @@ afterAll(async () => { }) beforeEach(async () => { + await db.execute(sql`delete from admin_log`) await db.execute(sql`delete from private_message_copies`) await db.execute(sql`delete from private_messages`) await db.execute(sql`delete from report_events`) @@ -408,6 +411,126 @@ describe('assignment and closing', () => { }) }) +describe('the moderator log a closed report leaves', () => { + async function logRows(): Promise< + Array<{ user_id: number; action: string; detail: Record }> + > { + return resultRows( + await db.execute(sql`select user_id, action, detail from admin_log order by id`), + ) as Array<{ user_id: number; action: string; detail: Record }> + } + + async function openOn(forumId: number): Promise { + const postId = await seedThread(100, forumId) + const { reportId } = await service().file({ + kind: 'post', + targetId: postId, + reason: 'spam', + reporterUserId: REPORTER, + }) + return reportId + } + + it('names who resolved it, which report, and the forum it was filed in', async () => { + const id = await openOn(FORUM) + await repo.close({ + reportId: id, + status: 'resolved', + note: 'warned the member', + actorUserId: MOD, + at: AT, + }) + + const rows = await logRows() + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ user_id: MOD, action: 'report.resolve' }) + expect(rows[0]!.detail).toEqual({ reportId: id, forumId: FORUM, forumIds: [FORUM] }) + }) + + it('separates a dismissal from a resolution', async () => { + const id = await openOn(FORUM) + await repo.close({ + reportId: id, + status: 'rejected', + note: null, + actorUserId: MOD, + at: AT, + }) + + expect((await logRows())[0]).toMatchObject({ action: 'report.reject' }) + }) + + it('keeps the private note out of the shared log', async () => { + const id = await openOn(FORUM) + await repo.close({ + reportId: id, + status: 'resolved', + note: 'banned them for the third time', + actorUserId: MOD, + at: AT, + }) + + expect(JSON.stringify(await logRows())).not.toContain('third time') + }) + + it('carries no forum key for a report about a member rather than a post', async () => { + const { reportId } = await service().file({ + kind: 'user', + targetId: AUTHOR, + reason: 'spam', + reporterUserId: REPORTER, + }) + await repo.close({ + reportId, + status: 'resolved', + note: null, + actorUserId: MOD, + at: AT, + }) + + expect((await logRows())[0]!.detail).toEqual({ reportId }) + }) + + it('appears in the administrator log the panel lists, named after who closed it', async () => { + const id = await openOn(FORUM) + await repo.close({ reportId: id, status: 'resolved', note: null, actorUserId: MOD, at: AT }) + + const listed = await new PostgresAdminLogRepository(db).list({ limit: 10 }) + + expect(listed[0]).toMatchObject({ + action: 'report.resolve', + userId: MOD, + username: 'mod', + detail: { reportId: id, forumId: FORUM }, + }) + }) + + it('appears in the moderator log of the forum the report was filed in', async () => { + const id = await openOn(FORUM) + await repo.close({ reportId: id, status: 'rejected', note: null, actorUserId: MOD, at: AT }) + + const page = await new PostgresModCpRepository(db).log({ + forumIds: [FORUM], + actorUserId: REPORTER, + limit: 10, + }) + + expect(page.entries[0]).toMatchObject({ + action: 'report.reject', + forumTitle: 'General', + actorUsername: 'mod', + }) + }) + + it('writes nothing when the report was already closed', async () => { + const id = await openOn(FORUM) + await repo.close({ reportId: id, status: 'resolved', note: null, actorUserId: MOD, at: AT }) + await repo.close({ reportId: id, status: 'rejected', note: null, actorUserId: MOD, at: AT }) + + expect(await logRows()).toHaveLength(1) + }) +}) + describe('through the service', () => { it('will not open, assign or close a report outside the actor"s scope', async () => { const postId = await seedThread(100, OTHER) diff --git a/packages/db/src/report-repo.ts b/packages/db/src/report-repo.ts index e43f734c..670ea942 100644 --- a/packages/db/src/report-repo.ts +++ b/packages/db/src/report-repo.ts @@ -16,6 +16,7 @@ import type { Database } from './client' import { decodeCursor, encodeCursor } from './cursor' import { resultRows } from './result-rows' import { idList } from './sql-lists' +import { logModeratorAction } from './thread-counters' import { visibleIn } from './visibility' import { threads } from './schema' @@ -299,16 +300,29 @@ export class PostgresReportRepository implements ReportRepository { resolved_at = ${input.at}, updated_at = ${input.at} where id = ${input.reportId} and status = 'open' - returning id + returning id, forum_id `), - ) as Array<{ id: number }> - if (moved.length === 0) return false + ) as Array<{ id: number; forum_id: number | null }> + const row = moved[0] + if (!row) return false await tx.execute(sql` insert into report_events (report_id, actor_user_id, kind, note, created_at) values (${input.reportId}, ${input.actorUserId}, ${input.status}, ${input.note}, ${input.at}) `) + + const forumId = row.forum_id === null ? null : Number(row.forum_id) + await logModeratorAction( + tx, + input.status === 'resolved' ? 'report.resolve' : 'report.reject', + input.actorUserId, + { + reportId: input.reportId, + ...(forumId === null ? {} : { forumId, forumIds: [forumId] }), + }, + input.at, + ) return true }) } diff --git a/packages/db/src/thread-tools.test.ts b/packages/db/src/thread-tools.test.ts index b1663914..743d1ef3 100644 --- a/packages/db/src/thread-tools.test.ts +++ b/packages/db/src/thread-tools.test.ts @@ -537,7 +537,7 @@ describe('copy', () => { expect(Number(rows[0]!.reply_count)).toBe(1) }) - it('logs the act with both thread ids', async () => { + it('logs the act with both thread ids and both forums', async () => { const { threadId } = await seedThread(LEFT) const copy = await repo.copy({ threadId, toForumId: RIGHT, actorUserId: MOD, at: AT }) @@ -547,12 +547,24 @@ describe('copy', () => { expect(rows).toHaveLength(1) expect(rows[0]!.detail).toMatchObject({ threadId, + fromForumId: LEFT, toForumId: RIGHT, + forumIds: [LEFT, RIGHT], newThreadId: copy.threadId, posts: 2, }) }) + it('names one forum once when a thread is copied within its own forum', async () => { + const { threadId } = await seedThread(LEFT) + await repo.copy({ threadId, toForumId: LEFT, actorUserId: MOD, at: AT }) + + const rows = resultRows( + await db.execute(sql`select detail from admin_log where action = 'thread.copy'`), + ) as Array<{ detail: Record }> + expect(rows[0]!.detail).toMatchObject({ forumIds: [LEFT] }) + }) + it('puts the copies in the roll-up ledger', async () => { const { threadId } = await seedThread(LEFT) const copy = await repo.copy({ threadId, toForumId: RIGHT, actorUserId: MOD, at: AT }) diff --git a/packages/db/src/thread-tools.ts b/packages/db/src/thread-tools.ts index 0bb027a9..8938118e 100644 --- a/packages/db/src/thread-tools.ts +++ b/packages/db/src/thread-tools.ts @@ -218,11 +218,12 @@ export class PostgresThreadToolsRepository implements ThreadToolsRepository { return this.db.transaction(async (tx) => { const sourceRows = resultRows( await tx.execute(sql` - select id, title, slug, prefix_id, author_user_id, author_username + select id, forum_id, title, slug, prefix_id, author_user_id, author_username from threads where id = ${input.threadId} `), ) as Array<{ id: number + forum_id: number title: string slug: string prefix_id: number | null @@ -294,13 +295,16 @@ export class PostgresThreadToolsRepository implements ThreadToolsRepository { await repairThreadLastPost(tx, newThreadId) await repairForumLastPostChain(tx, input.toForumId) + const fromForumId = Number(source.forum_id) await log( tx, 'thread.copy', input.actorUserId, { threadId: input.threadId, + fromForumId, toForumId: input.toForumId, + forumIds: [...new Set([fromForumId, input.toForumId])], newThreadId, posts: copied.length, }, diff --git a/packages/moderation/src/modcp.test.ts b/packages/moderation/src/modcp.test.ts index 19efe990..17ed6a29 100644 --- a/packages/moderation/src/modcp.test.ts +++ b/packages/moderation/src/modcp.test.ts @@ -198,6 +198,8 @@ describe('the log allow-list', () => { expect(MOD_LOG_ACTIONS).not.toContain('settings.update') expect(MOD_LOG_ACTIONS).not.toContain('permission.bypass') expect(MOD_LOG_ACTIONS).not.toContain('user.promote') + expect(MOD_LOG_ACTIONS).not.toContain('user.mass_mail_started') + expect(MOD_LOG_ACTIONS).not.toContain('user.mass_mail_continued') }) it('reads a flag being cleared as clearing it, not as setting it', () => { @@ -218,12 +220,31 @@ describe('the log allow-list', () => { 'thread.move', 'thread.split', 'thread.merge', + 'thread.copy', 'inline.delete', + 'post.edit', + 'post.delete', + 'post.restore', + 'report.resolve', + 'report.reject', 'warning.issue', 'warning.revoke', + 'signature.lock', + 'signature.unlock', + 'avatar.lock', + 'avatar.unlock', 'modcp.ip_lookup', ]) { expect(MOD_LOG_ACTIONS).toContain(action) } }) + + it('reads a lock and its release as two different things, for every lockable thing', () => { + for (const [set, cleared] of [ + ['signature.lock', 'signature.unlock'], + ['avatar.lock', 'avatar.unlock'], + ]) { + expect(MOD_LOG_LABELS[set!]).not.toBe(MOD_LOG_LABELS[cleared!]) + } + }) }) diff --git a/packages/moderation/src/modcp.ts b/packages/moderation/src/modcp.ts index 47749975..e147508d 100644 --- a/packages/moderation/src/modcp.ts +++ b/packages/moderation/src/modcp.ts @@ -146,6 +146,7 @@ export const MOD_LOG_LABELS: Readonly> = { 'thread.restore': 'Restored a thread', 'thread.split': 'Split a thread', 'thread.merge': 'Merged two threads', + 'thread.copy': 'Copied a thread', 'inline.approve': 'Approved a selection', 'inline.delete': 'Deleted a selection', 'inline.restore': 'Restored a selection', @@ -154,10 +155,17 @@ export const MOD_LOG_LABELS: Readonly> = { 'inline.stick': 'Pinned a selection', 'inline.unstick': 'Unpinned a selection', 'inline.move': 'Moved a selection', + 'post.edit': 'Edited a post they did not write', + 'post.delete': 'Deleted a post', + 'post.restore': 'Restored a post', 'report.resolve': 'Resolved a report', 'report.reject': 'Rejected a report', 'warning.issue': 'Issued a warning', 'warning.revoke': 'Revoked a warning', + 'signature.lock': 'Locked a signature', + 'signature.unlock': 'Unlocked a signature', + 'avatar.lock': 'Locked an avatar', + 'avatar.unlock': 'Unlocked an avatar', 'modcp.ip_lookup': 'Looked up an address', }