From 4a6b60c843f3499e95f170a784eb24bf57652466 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Sun, 21 Jun 2026 02:23:46 -0700 Subject: [PATCH 1/4] Fix ModNote.fromProto to map modActionData to modAction reddit.getModNotes() already receives modActionData from the Reddit API, but #fromProto never populated modAction on the returned ModNote objects. Map action, details, description, and target redditId so callers can read ban/removal/approval metadata without bypassing the client. Co-authored-by: Cursor --- .../src/apis/reddit/models/ModNote.ts | 31 ++++++++++- .../src/apis/reddit/tests/modnote.api.test.ts | 49 ++++++++++++++++++ packages/reddit/src/models/ModNote.ts | 31 ++++++++++- packages/reddit/src/tests/modnote.api.test.ts | 51 +++++++++++++++++++ 4 files changed, 158 insertions(+), 4 deletions(-) diff --git a/packages/public-api/src/apis/reddit/models/ModNote.ts b/packages/public-api/src/apis/reddit/models/ModNote.ts index c0bd0916d..c21200a0c 100644 --- a/packages/public-api/src/apis/reddit/models/ModNote.ts +++ b/packages/public-api/src/apis/reddit/models/ModNote.ts @@ -14,7 +14,7 @@ import type { T1ID, T2ID, T3ID, T5ID } from '../../../types/tid.js'; import { asT2ID, asT5ID, asTID } from '../../../types/tid.js'; import type { ListingFetchOptions, ListingFetchResponse } from './Listing.js'; import { Listing } from './Listing.js'; -import type { ModAction } from './ModAction.js'; +import type { ModAction, ModActionType } from './ModAction.js'; export type ModNoteType = | 'NOTE' @@ -101,6 +101,9 @@ export class ModNote { assertNonNull(protoModNote.userNoteData, 'Mod note userNote is null or undefined'); assertNonNull(protoModNote.modActionData, 'Mod note modAction is null or undefined'); + const createdAt = new Date(protoModNote.createdAt! * 1000); // convert to ms + const modAction = this.#modActionDataToModAction(protoModNote, createdAt); + return { id: protoModNote.id, user: { @@ -115,7 +118,7 @@ export class ModNote { id: asT2ID(protoModNote.operatorId ?? ''), name: protoModNote.operator, }, - createdAt: new Date(protoModNote.createdAt! * 1000), // convert to ms + createdAt, userNote: { note: protoModNote.userNoteData?.note, redditId: protoModNote.userNoteData?.redditId @@ -124,6 +127,30 @@ export class ModNote { label: protoModNote.userNoteData?.label as UserNoteLabel, }, type: protoModNote.type as ModNoteType, + modAction, + }; + } + + static #modActionDataToModAction( + protoModNote: ModNoteObject, + createdAt: Date + ): ModAction | undefined { + const modActionData = protoModNote.modActionData; + if (!modActionData?.action) { + return undefined; + } + + return { + id: protoModNote.id!, + type: modActionData.action as ModActionType, + moderatorName: protoModNote.operator ?? '', + moderatorId: asT2ID(protoModNote.operatorId ?? ''), + createdAt, + subredditName: protoModNote.subreddit ?? '', + subredditId: asT5ID(protoModNote.subredditId ?? ''), + description: modActionData.description, + details: modActionData.details, + target: modActionData.redditId ? { id: modActionData.redditId } : undefined, }; } diff --git a/packages/public-api/src/apis/reddit/tests/modnote.api.test.ts b/packages/public-api/src/apis/reddit/tests/modnote.api.test.ts index d6666bd71..42dccd7f1 100644 --- a/packages/public-api/src/apis/reddit/tests/modnote.api.test.ts +++ b/packages/public-api/src/apis/reddit/tests/modnote.api.test.ts @@ -13,6 +13,55 @@ describe('ModNote API', () => { }); describe('RedditAPIClient:ModNote', () => { + test('getModNotes() maps modActionData to modAction', async () => { + const spyPlugin = vi.spyOn(Devvit.redditAPIPlugins.ModNote, 'GetNotes'); + spyPlugin.mockImplementationOnce(async () => ({ + modNotes: [ + { + id: 'ModNote_test', + createdAt: 1_709_251_200, + type: 'MOD_ACTION', + subreddit: 'testsub', + subredditId: 't5_test', + user: 'test-user', + userId: 't2_user', + operator: 'test-mod', + operatorId: 't2_mod', + userNoteData: {}, + modActionData: { + action: 'banuser', + details: '14 day ban', + description: 'Second ban', + }, + }, + ], + hasNextPage: false, + })); + + const notes = await api.reddit + .getModNotes({ + subreddit: 'testsub', + user: 'test-user', + filter: 'MOD_ACTION', + limit: 100, + }) + .all(); + + expect(notes).toHaveLength(1); + expect(notes[0]?.modAction).toEqual({ + id: 'ModNote_test', + type: 'banuser', + moderatorName: 'test-mod', + moderatorId: 't2_mod', + createdAt: new Date('2024-03-01T00:00:00Z'), + subredditName: 'testsub', + subredditId: 't5_test', + description: 'Second ban', + details: '14 day ban', + target: undefined, + }); + }); + test('addRemovalNote()', async () => { const spyPlugin = vi.spyOn(Devvit.redditAPIPlugins.ModNote, 'PostRemovalNote'); spyPlugin.mockImplementationOnce(async () => ({})); diff --git a/packages/reddit/src/models/ModNote.ts b/packages/reddit/src/models/ModNote.ts index 3a8472137..2769be727 100644 --- a/packages/reddit/src/models/ModNote.ts +++ b/packages/reddit/src/models/ModNote.ts @@ -14,7 +14,7 @@ import { asTid, T1, T2, T3, T5 } from '@devvit/shared-types/tid.js'; import { getRedditApiPlugins } from '../plugin.js'; import type { ListingFetchOptions, ListingFetchResponse } from './Listing.js'; import { Listing } from './Listing.js'; -import type { ModAction } from './ModAction.js'; +import type { ModAction, ModActionType } from './ModAction.js'; export type ModNoteType = | 'NOTE' @@ -101,6 +101,9 @@ export class ModNote { assertNonNull(protoModNote.userNoteData, 'Mod note userNote is null or undefined'); assertNonNull(protoModNote.modActionData, 'Mod note modAction is null or undefined'); + const createdAt = new Date(protoModNote.createdAt! * 1000); // convert to ms + const modAction = this.#modActionDataToModAction(protoModNote, createdAt); + return { id: protoModNote.id, user: { @@ -115,7 +118,7 @@ export class ModNote { id: T2(protoModNote.operatorId ?? ''), name: protoModNote.operator, }, - createdAt: new Date(protoModNote.createdAt! * 1000), // convert to ms + createdAt, userNote: { note: protoModNote.userNoteData?.note, redditId: protoModNote.userNoteData?.redditId @@ -124,6 +127,30 @@ export class ModNote { label: protoModNote.userNoteData?.label as UserNoteLabel, }, type: protoModNote.type as ModNoteType, + modAction, + }; + } + + static #modActionDataToModAction( + protoModNote: ModNoteObject, + createdAt: Date + ): ModAction | undefined { + const modActionData = protoModNote.modActionData; + if (!modActionData?.action) { + return undefined; + } + + return { + id: protoModNote.id!, + type: modActionData.action as ModActionType, + moderatorName: protoModNote.operator ?? '', + moderatorId: T2(protoModNote.operatorId ?? ''), + createdAt, + subredditName: protoModNote.subreddit ?? '', + subredditId: T5(protoModNote.subredditId ?? ''), + description: modActionData.description, + details: modActionData.details, + target: modActionData.redditId ? { id: modActionData.redditId } : undefined, }; } diff --git a/packages/reddit/src/tests/modnote.api.test.ts b/packages/reddit/src/tests/modnote.api.test.ts index 095bb5402..b11d3930a 100644 --- a/packages/reddit/src/tests/modnote.api.test.ts +++ b/packages/reddit/src/tests/modnote.api.test.ts @@ -17,6 +17,57 @@ describe('ModNote API', () => { const redditAPI = new RedditClient(); describe('RedditClient:ModNote', () => { + test('getModNotes() maps modActionData to modAction', async () => { + const spyPlugin = redditApiPlugins.ModNote.GetNotes; + spyPlugin.mockImplementationOnce(async () => ({ + modNotes: [ + { + id: 'ModNote_test', + createdAt: 1_709_251_200, + type: 'MOD_ACTION', + subreddit: 'testsub', + subredditId: 't5_test', + user: 'test-user', + userId: 't2_user', + operator: 'test-mod', + operatorId: 't2_mod', + userNoteData: {}, + modActionData: { + action: 'banuser', + details: '14 day ban', + description: 'Second ban', + }, + }, + ], + hasNextPage: false, + })); + + await runWithTestContext(async () => { + const notes = await redditAPI + .getModNotes({ + subreddit: 'testsub', + user: 'test-user', + filter: 'MOD_ACTION', + limit: 100, + }) + .all(); + + expect(notes).toHaveLength(1); + expect(notes[0]?.modAction).toEqual({ + id: 'ModNote_test', + type: 'banuser', + moderatorName: 'test-mod', + moderatorId: 't2_mod', + createdAt: new Date('2024-03-01T00:00:00Z'), + subredditName: 'testsub', + subredditId: 't5_test', + description: 'Second ban', + details: '14 day ban', + target: undefined, + }); + }); + }); + test('addRemovalNote()', async () => { const spyPlugin = redditApiPlugins.ModNote.PostRemovalNote; spyPlugin.mockImplementationOnce(async () => ({})); From 2f07a744d2ffb8ec7578e254de9430e12ec3e6f6 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Sun, 21 Jun 2026 02:57:30 -0700 Subject: [PATCH 2/4] Use ModNoteAction for mod note modActionData mapping. Introduce a focused ModNoteAction type instead of reusing ModAction, and map modActionData inline in fromProto with typed redditId conversion. Co-authored-by: Cursor --- .../src/apis/reddit/models/ModNote.ts | 57 +++++++++---------- .../src/apis/reddit/tests/modnote.api.test.ts | 11 +--- packages/reddit/src/models/ModNote.ts | 49 +++++++--------- packages/reddit/src/tests/modnote.api.test.ts | 11 +--- 4 files changed, 54 insertions(+), 74 deletions(-) diff --git a/packages/public-api/src/apis/reddit/models/ModNote.ts b/packages/public-api/src/apis/reddit/models/ModNote.ts index c21200a0c..9be35c4cf 100644 --- a/packages/public-api/src/apis/reddit/models/ModNote.ts +++ b/packages/public-api/src/apis/reddit/models/ModNote.ts @@ -14,7 +14,7 @@ import type { T1ID, T2ID, T3ID, T5ID } from '../../../types/tid.js'; import { asT2ID, asT5ID, asTID } from '../../../types/tid.js'; import type { ListingFetchOptions, ListingFetchResponse } from './Listing.js'; import { Listing } from './Listing.js'; -import type { ModAction, ModActionType } from './ModAction.js'; +import type { ModActionType } from './ModAction.js'; export type ModNoteType = | 'NOTE' @@ -28,6 +28,15 @@ export type ModNoteType = | 'MOD_ACTION' | 'ALL'; +export type ModNoteAction = { + action: ModActionType; + redditId?: T1ID | T2ID | T3ID | undefined; + /** For `banuser` actions, the number of days in a format like `"1 days"` */ + details?: string | undefined; + /** For `banuser` actions, the reasoning for the ban */ + description?: string | undefined; +}; + export type UserNoteLabel = | 'BOT_BAN' | 'PERMA_BAN' @@ -39,9 +48,9 @@ export type UserNoteLabel = | 'HELPFUL_USER'; export type UserNote = { - note?: string; - redditId?: T1ID | T3ID | T5ID; - label?: UserNoteLabel; + note?: string | undefined; + redditId?: T1ID | T3ID | T5ID | undefined; + label?: UserNoteLabel | undefined; }; export interface ModNote { @@ -60,8 +69,8 @@ export interface ModNote { }; type: ModNoteType; createdAt: Date; - userNote?: UserNote; - modAction?: ModAction; + userNote?: UserNote | undefined; + modAction?: ModNoteAction; } export type GetModNotesOptions = Prettify< @@ -102,7 +111,7 @@ export class ModNote { assertNonNull(protoModNote.modActionData, 'Mod note modAction is null or undefined'); const createdAt = new Date(protoModNote.createdAt! * 1000); // convert to ms - const modAction = this.#modActionDataToModAction(protoModNote, createdAt); + const modActionData = protoModNote.modActionData; return { id: protoModNote.id, @@ -127,30 +136,16 @@ export class ModNote { label: protoModNote.userNoteData?.label as UserNoteLabel, }, type: protoModNote.type as ModNoteType, - modAction, - }; - } - - static #modActionDataToModAction( - protoModNote: ModNoteObject, - createdAt: Date - ): ModAction | undefined { - const modActionData = protoModNote.modActionData; - if (!modActionData?.action) { - return undefined; - } - - return { - id: protoModNote.id!, - type: modActionData.action as ModActionType, - moderatorName: protoModNote.operator ?? '', - moderatorId: asT2ID(protoModNote.operatorId ?? ''), - createdAt, - subredditName: protoModNote.subreddit ?? '', - subredditId: asT5ID(protoModNote.subredditId ?? ''), - description: modActionData.description, - details: modActionData.details, - target: modActionData.redditId ? { id: modActionData.redditId } : undefined, + modAction: modActionData?.action + ? { + action: modActionData.action as ModActionType, + redditId: modActionData.redditId + ? asTID(modActionData.redditId) + : undefined, + details: modActionData.details, + description: modActionData.description, + } + : undefined, }; } diff --git a/packages/public-api/src/apis/reddit/tests/modnote.api.test.ts b/packages/public-api/src/apis/reddit/tests/modnote.api.test.ts index 42dccd7f1..3e1673b5b 100644 --- a/packages/public-api/src/apis/reddit/tests/modnote.api.test.ts +++ b/packages/public-api/src/apis/reddit/tests/modnote.api.test.ts @@ -32,6 +32,7 @@ describe('ModNote API', () => { action: 'banuser', details: '14 day ban', description: 'Second ban', + redditId: 't2_target', }, }, ], @@ -49,16 +50,10 @@ describe('ModNote API', () => { expect(notes).toHaveLength(1); expect(notes[0]?.modAction).toEqual({ - id: 'ModNote_test', - type: 'banuser', - moderatorName: 'test-mod', - moderatorId: 't2_mod', - createdAt: new Date('2024-03-01T00:00:00Z'), - subredditName: 'testsub', - subredditId: 't5_test', + action: 'banuser', description: 'Second ban', details: '14 day ban', - target: undefined, + redditId: 't2_target', }); }); diff --git a/packages/reddit/src/models/ModNote.ts b/packages/reddit/src/models/ModNote.ts index 2769be727..96d0bebcc 100644 --- a/packages/reddit/src/models/ModNote.ts +++ b/packages/reddit/src/models/ModNote.ts @@ -14,7 +14,7 @@ import { asTid, T1, T2, T3, T5 } from '@devvit/shared-types/tid.js'; import { getRedditApiPlugins } from '../plugin.js'; import type { ListingFetchOptions, ListingFetchResponse } from './Listing.js'; import { Listing } from './Listing.js'; -import type { ModAction, ModActionType } from './ModAction.js'; +import type { ModActionType } from './ModAction.js'; export type ModNoteType = | 'NOTE' @@ -28,6 +28,15 @@ export type ModNoteType = | 'MOD_ACTION' | 'ALL'; +export type ModNoteAction = { + action: ModActionType; + redditId?: T1 | T2 | T3 | undefined; + /** For `banuser` actions, the number of days in a format like `"1 days"` */ + details?: string | undefined; + /** For `banuser` actions, the reasoning for the ban */ + description?: string | undefined; +}; + export type UserNoteLabel = | 'BOT_BAN' | 'PERMA_BAN' @@ -61,7 +70,7 @@ export interface ModNote { type: ModNoteType; createdAt: Date; userNote?: UserNote | undefined; - modAction?: ModAction; + modAction?: ModNoteAction; } export type GetModNotesOptions = Prettify< @@ -102,7 +111,7 @@ export class ModNote { assertNonNull(protoModNote.modActionData, 'Mod note modAction is null or undefined'); const createdAt = new Date(protoModNote.createdAt! * 1000); // convert to ms - const modAction = this.#modActionDataToModAction(protoModNote, createdAt); + const modActionData = protoModNote.modActionData; return { id: protoModNote.id, @@ -127,30 +136,16 @@ export class ModNote { label: protoModNote.userNoteData?.label as UserNoteLabel, }, type: protoModNote.type as ModNoteType, - modAction, - }; - } - - static #modActionDataToModAction( - protoModNote: ModNoteObject, - createdAt: Date - ): ModAction | undefined { - const modActionData = protoModNote.modActionData; - if (!modActionData?.action) { - return undefined; - } - - return { - id: protoModNote.id!, - type: modActionData.action as ModActionType, - moderatorName: protoModNote.operator ?? '', - moderatorId: T2(protoModNote.operatorId ?? ''), - createdAt, - subredditName: protoModNote.subreddit ?? '', - subredditId: T5(protoModNote.subredditId ?? ''), - description: modActionData.description, - details: modActionData.details, - target: modActionData.redditId ? { id: modActionData.redditId } : undefined, + modAction: modActionData?.action + ? { + action: modActionData.action as ModActionType, + redditId: modActionData.redditId + ? asTid(modActionData.redditId) + : undefined, + details: modActionData.details, + description: modActionData.description, + } + : undefined, }; } diff --git a/packages/reddit/src/tests/modnote.api.test.ts b/packages/reddit/src/tests/modnote.api.test.ts index b11d3930a..960e5f557 100644 --- a/packages/reddit/src/tests/modnote.api.test.ts +++ b/packages/reddit/src/tests/modnote.api.test.ts @@ -36,6 +36,7 @@ describe('ModNote API', () => { action: 'banuser', details: '14 day ban', description: 'Second ban', + redditId: 't2_target', }, }, ], @@ -54,16 +55,10 @@ describe('ModNote API', () => { expect(notes).toHaveLength(1); expect(notes[0]?.modAction).toEqual({ - id: 'ModNote_test', - type: 'banuser', - moderatorName: 'test-mod', - moderatorId: 't2_mod', - createdAt: new Date('2024-03-01T00:00:00Z'), - subredditName: 'testsub', - subredditId: 't5_test', + action: 'banuser', description: 'Second ban', details: '14 day ban', - target: undefined, + redditId: 't2_target', }); }); }); From f0c5a1297313dd08b41447a6472130c000acbd34 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Sun, 21 Jun 2026 02:58:27 -0700 Subject: [PATCH 3/4] Document why ModNoteAction is separate from ModAction. Explain that mod notes and moderation log entries are distinct use cases with overlapping context already on ModNote. Co-authored-by: Cursor --- packages/public-api/src/apis/reddit/models/ModNote.ts | 6 ++++++ packages/reddit/src/models/ModNote.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/packages/public-api/src/apis/reddit/models/ModNote.ts b/packages/public-api/src/apis/reddit/models/ModNote.ts index 9be35c4cf..67678a0fc 100644 --- a/packages/public-api/src/apis/reddit/models/ModNote.ts +++ b/packages/public-api/src/apis/reddit/models/ModNote.ts @@ -28,6 +28,12 @@ export type ModNoteType = | 'MOD_ACTION' | 'ALL'; +/** + * Action metadata from a mod note's `modActionData`. Kept separate from + * {@link ModAction} to reduce field duplication — mod notes and moderation + * log entries are distinct use cases, and {@link ModNote} already provides + * moderator, subreddit, and timestamp context. + */ export type ModNoteAction = { action: ModActionType; redditId?: T1ID | T2ID | T3ID | undefined; diff --git a/packages/reddit/src/models/ModNote.ts b/packages/reddit/src/models/ModNote.ts index 96d0bebcc..1a23fe6d8 100644 --- a/packages/reddit/src/models/ModNote.ts +++ b/packages/reddit/src/models/ModNote.ts @@ -28,6 +28,12 @@ export type ModNoteType = | 'MOD_ACTION' | 'ALL'; +/** + * Action metadata from a mod note's `modActionData`. Kept separate from + * {@link ModAction} to reduce field duplication — mod notes and moderation + * log entries are distinct use cases, and {@link ModNote} already provides + * moderator, subreddit, and timestamp context. + */ export type ModNoteAction = { action: ModActionType; redditId?: T1 | T2 | T3 | undefined; From 572d1dcefdaf40a273e77127018cfa4b1206a8cc Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Sun, 21 Jun 2026 03:05:30 -0700 Subject: [PATCH 4/4] Revert incidental ModNote type and fromProto cleanups. Restore the original UserNote shape, userNote optional typing, and inline createdAt conversion. Co-authored-by: Cursor --- packages/public-api/src/apis/reddit/models/ModNote.ts | 11 +++++------ packages/reddit/src/models/ModNote.ts | 5 ++--- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/public-api/src/apis/reddit/models/ModNote.ts b/packages/public-api/src/apis/reddit/models/ModNote.ts index 67678a0fc..4d2f1c32a 100644 --- a/packages/public-api/src/apis/reddit/models/ModNote.ts +++ b/packages/public-api/src/apis/reddit/models/ModNote.ts @@ -54,9 +54,9 @@ export type UserNoteLabel = | 'HELPFUL_USER'; export type UserNote = { - note?: string | undefined; - redditId?: T1ID | T3ID | T5ID | undefined; - label?: UserNoteLabel | undefined; + note?: string; + redditId?: T1ID | T3ID | T5ID; + label?: UserNoteLabel; }; export interface ModNote { @@ -75,7 +75,7 @@ export interface ModNote { }; type: ModNoteType; createdAt: Date; - userNote?: UserNote | undefined; + userNote?: UserNote; modAction?: ModNoteAction; } @@ -116,7 +116,6 @@ export class ModNote { assertNonNull(protoModNote.userNoteData, 'Mod note userNote is null or undefined'); assertNonNull(protoModNote.modActionData, 'Mod note modAction is null or undefined'); - const createdAt = new Date(protoModNote.createdAt! * 1000); // convert to ms const modActionData = protoModNote.modActionData; return { @@ -133,7 +132,7 @@ export class ModNote { id: asT2ID(protoModNote.operatorId ?? ''), name: protoModNote.operator, }, - createdAt, + createdAt: new Date(protoModNote.createdAt! * 1000), // convert to ms userNote: { note: protoModNote.userNoteData?.note, redditId: protoModNote.userNoteData?.redditId diff --git a/packages/reddit/src/models/ModNote.ts b/packages/reddit/src/models/ModNote.ts index 1a23fe6d8..9fd8e9bad 100644 --- a/packages/reddit/src/models/ModNote.ts +++ b/packages/reddit/src/models/ModNote.ts @@ -75,7 +75,7 @@ export interface ModNote { }; type: ModNoteType; createdAt: Date; - userNote?: UserNote | undefined; + userNote?: UserNote; modAction?: ModNoteAction; } @@ -116,7 +116,6 @@ export class ModNote { assertNonNull(protoModNote.userNoteData, 'Mod note userNote is null or undefined'); assertNonNull(protoModNote.modActionData, 'Mod note modAction is null or undefined'); - const createdAt = new Date(protoModNote.createdAt! * 1000); // convert to ms const modActionData = protoModNote.modActionData; return { @@ -133,7 +132,7 @@ export class ModNote { id: T2(protoModNote.operatorId ?? ''), name: protoModNote.operator, }, - createdAt, + createdAt: new Date(protoModNote.createdAt! * 1000), // convert to ms userNote: { note: protoModNote.userNoteData?.note, redditId: protoModNote.userNoteData?.redditId