Skip to content

Commit 5509a6a

Browse files
authored
Merge pull request #291 from mchestr/feat/discord-marked-media
Admin Discord: Marked Media view (see what users marked)
2 parents ab07212 + 81d5313 commit 5509a6a

4 files changed

Lines changed: 667 additions & 8 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Tests for actions/discord-activity.ts - getDiscordMarkedMedia
3+
*
4+
* Covers: admin gating, per-type summary, mark-type/source/search/date filters,
5+
* pagination, serialization, and error handling.
6+
*/
7+
8+
import { getDiscordMarkedMedia } from "@/actions/discord-activity"
9+
import { requireAdmin } from "@/lib/admin"
10+
import { prisma } from "@/lib/prisma"
11+
12+
jest.mock("@/lib/admin", () => ({
13+
requireAdmin: jest.fn(),
14+
}))
15+
16+
jest.mock("@/lib/prisma", () => ({
17+
prisma: {
18+
userMediaMark: {
19+
findMany: jest.fn(),
20+
count: jest.fn(),
21+
groupBy: jest.fn(),
22+
},
23+
},
24+
}))
25+
26+
jest.mock("@/lib/utils/logger", () => ({
27+
createLogger: () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn() }),
28+
}))
29+
30+
const mockRequireAdmin = requireAdmin as jest.MockedFunction<typeof requireAdmin>
31+
const findMany = prisma.userMediaMark.findMany as jest.Mock
32+
const count = prisma.userMediaMark.count as jest.Mock
33+
const groupBy = prisma.userMediaMark.groupBy as jest.Mock
34+
35+
function makeRow(overrides: Record<string, unknown> = {}) {
36+
return {
37+
id: "mark-1",
38+
title: "The Office",
39+
year: 2005,
40+
mediaType: "TV_SERIES",
41+
markType: "KEEP_FOREVER",
42+
seasonNumber: null,
43+
episodeNumber: null,
44+
parentTitle: null,
45+
note: null,
46+
markedVia: "discord",
47+
markedAt: new Date("2026-07-01T12:00:00Z"),
48+
radarrTitleSlug: null,
49+
sonarrTitleSlug: "the-office",
50+
user: { id: "u1", name: "Alice", email: "a@x.com", image: null },
51+
...overrides,
52+
}
53+
}
54+
55+
beforeEach(() => {
56+
jest.clearAllMocks()
57+
mockRequireAdmin.mockResolvedValue(undefined)
58+
findMany.mockResolvedValue([])
59+
count.mockResolvedValue(0)
60+
groupBy.mockResolvedValue([])
61+
})
62+
63+
describe("getDiscordMarkedMedia", () => {
64+
it("requires admin access", async () => {
65+
await getDiscordMarkedMedia()
66+
expect(mockRequireAdmin).toHaveBeenCalled()
67+
})
68+
69+
it("returns a per-type summary covering all mark types (zero-filled)", async () => {
70+
groupBy.mockResolvedValue([
71+
{ markType: "KEEP_FOREVER", _count: { _all: 3 } },
72+
{ markType: "POOR_QUALITY", _count: { _all: 1 } },
73+
])
74+
75+
const result = await getDiscordMarkedMedia()
76+
77+
expect(result.success).toBe(true)
78+
// All 6 mark types present, missing ones zero-filled.
79+
expect(result.summary).toHaveLength(6)
80+
const byType = Object.fromEntries(result.summary.map((s) => [s.markType, s.count]))
81+
expect(byType.KEEP_FOREVER).toBe(3)
82+
expect(byType.POOR_QUALITY).toBe(1)
83+
expect(byType.FINISHED_WATCHING).toBe(0)
84+
})
85+
86+
it("serializes marks with user info and ISO timestamps", async () => {
87+
findMany.mockResolvedValue([makeRow()])
88+
count.mockResolvedValue(1)
89+
90+
const result = await getDiscordMarkedMedia()
91+
92+
expect(result.marks).toHaveLength(1)
93+
expect(result.marks[0]).toMatchObject({
94+
title: "The Office",
95+
markType: "KEEP_FOREVER",
96+
markedVia: "discord",
97+
markedAt: "2026-07-01T12:00:00.000Z",
98+
user: { name: "Alice", email: "a@x.com" },
99+
})
100+
expect(result.total).toBe(1)
101+
})
102+
103+
it("filters the list by mark type but keeps the summary scoped to date/source only", async () => {
104+
await getDiscordMarkedMedia({ markType: "POOR_QUALITY" as never })
105+
106+
// List query includes markType…
107+
expect(findMany.mock.calls[0][0].where.markType).toBe("POOR_QUALITY")
108+
// …but the summary groupBy where does NOT (so counts stay comparable).
109+
expect(groupBy.mock.calls[0][0].where.markType).toBeUndefined()
110+
})
111+
112+
it("applies a case-insensitive title search to the list", async () => {
113+
await getDiscordMarkedMedia({ search: "office" })
114+
expect(findMany.mock.calls[0][0].where.title).toEqual({
115+
contains: "office",
116+
mode: "insensitive",
117+
})
118+
})
119+
120+
it("maps source=discord to markedVia and source=web through verbatim", async () => {
121+
await getDiscordMarkedMedia({ source: "discord" })
122+
expect(findMany.mock.calls[0][0].where.markedVia).toBe("discord")
123+
124+
jest.clearAllMocks()
125+
findMany.mockResolvedValue([])
126+
count.mockResolvedValue(0)
127+
groupBy.mockResolvedValue([])
128+
await getDiscordMarkedMedia({ source: "web" })
129+
expect(findMany.mock.calls[0][0].where.markedVia).toBe("web")
130+
})
131+
132+
it("does not filter by source when source is 'all'", async () => {
133+
await getDiscordMarkedMedia({ source: "all" })
134+
expect(findMany.mock.calls[0][0].where.markedVia).toBeUndefined()
135+
})
136+
137+
it("applies date range to markedAt", async () => {
138+
await getDiscordMarkedMedia({ startDate: "2026-07-01", endDate: "2026-07-31" })
139+
const where = findMany.mock.calls[0][0].where
140+
expect(where.markedAt.gte).toBeInstanceOf(Date)
141+
expect(where.markedAt.lt).toBeInstanceOf(Date)
142+
})
143+
144+
it("passes take/skip for pagination", async () => {
145+
await getDiscordMarkedMedia({ limit: 10, offset: 20 })
146+
expect(findMany.mock.calls[0][0].take).toBe(10)
147+
expect(findMany.mock.calls[0][0].skip).toBe(20)
148+
})
149+
150+
it("returns a safe error shape when the query throws", async () => {
151+
findMany.mockRejectedValue(new Error("db down"))
152+
const result = await getDiscordMarkedMedia()
153+
expect(result.success).toBe(false)
154+
expect(result.marks).toEqual([])
155+
expect(result.total).toBe(0)
156+
expect(result.summary).toEqual([])
157+
})
158+
})

actions/discord-activity.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import {
1818
import { prisma } from "@/lib/prisma"
1919
import { toEndOfDayExclusive } from "@/lib/utils/formatters"
2020
import { createLogger } from "@/lib/utils/logger"
21-
import type { DiscordCommandType, DiscordCommandStatus } from "@/lib/generated/prisma"
21+
import type {
22+
DiscordCommandType,
23+
DiscordCommandStatus,
24+
MarkType,
25+
Prisma,
26+
} from "@/lib/generated/prisma"
2227

2328
const logger = createLogger("DISCORD_ACTIVITY_ACTIONS")
2429

@@ -405,3 +410,118 @@ export async function getDiscordDetailedStats(params: GetStatsParams) {
405410
}
406411
}
407412
}
413+
414+
const ALL_MARK_TYPES: MarkType[] = [
415+
"FINISHED_WATCHING",
416+
"NOT_INTERESTED",
417+
"KEEP_FOREVER",
418+
"REWATCH_CANDIDATE",
419+
"POOR_QUALITY",
420+
"WRONG_VERSION",
421+
]
422+
423+
export interface GetMarkedMediaParams {
424+
markType?: MarkType
425+
/** "discord" | "web" | undefined (all sources). Matched against markedVia. */
426+
source?: string
427+
search?: string
428+
startDate?: string
429+
endDate?: string
430+
limit?: number
431+
offset?: number
432+
}
433+
434+
/**
435+
* Admin view of the ACTUAL media people have marked, sourced from the
436+
* `UserMediaMark` table (the source of truth) rather than inferred from command
437+
* log args. Answers "what did people mark as X?" with a per-type summary plus a
438+
* filterable, paginated list. Includes marks from every source (Discord + web)
439+
* with a `markedVia` badge.
440+
*/
441+
export async function getDiscordMarkedMedia(params: GetMarkedMediaParams = {}) {
442+
await requireAdmin()
443+
444+
try {
445+
const startDate = params.startDate ? new Date(params.startDate) : undefined
446+
const endDate = toEndOfDayExclusive(params.endDate)
447+
448+
// Date range applies to when the mark was made.
449+
const markedAtFilter: Prisma.DateTimeFilter = {}
450+
if (startDate) markedAtFilter.gte = startDate
451+
if (endDate) markedAtFilter.lt = endDate
452+
const hasDateFilter = startDate != null || endDate != null
453+
454+
const baseWhere: Prisma.UserMediaMarkWhereInput = {}
455+
if (hasDateFilter) baseWhere.markedAt = markedAtFilter
456+
if (params.source === "discord") baseWhere.markedVia = "discord"
457+
else if (params.source && params.source !== "all") baseWhere.markedVia = params.source
458+
459+
// Full filter also narrows by type + title search (summary ignores those so
460+
// the per-type counts always reflect the same date/source scope).
461+
const listWhere: Prisma.UserMediaMarkWhereInput = { ...baseWhere }
462+
if (params.markType) listWhere.markType = params.markType
463+
if (params.search && params.search.trim()) {
464+
listWhere.title = { contains: params.search.trim(), mode: "insensitive" }
465+
}
466+
467+
const limit = params.limit ?? 25
468+
const offset = params.offset ?? 0
469+
470+
const [rows, total, byTypeGroups] = await Promise.all([
471+
prisma.userMediaMark.findMany({
472+
where: listWhere,
473+
orderBy: { markedAt: "desc" },
474+
take: limit,
475+
skip: offset,
476+
include: {
477+
user: { select: { id: true, name: true, email: true, image: true } },
478+
},
479+
}),
480+
prisma.userMediaMark.count({ where: listWhere }),
481+
prisma.userMediaMark.groupBy({
482+
by: ["markType"],
483+
where: baseWhere,
484+
_count: { _all: true },
485+
}),
486+
])
487+
488+
const countByType = new Map(byTypeGroups.map((g) => [g.markType, g._count._all]))
489+
const summary = ALL_MARK_TYPES.map((markType) => ({
490+
markType,
491+
count: countByType.get(markType) ?? 0,
492+
}))
493+
494+
const marks = rows.map((row) => ({
495+
id: row.id,
496+
title: row.title,
497+
year: row.year,
498+
mediaType: row.mediaType,
499+
markType: row.markType,
500+
seasonNumber: row.seasonNumber,
501+
episodeNumber: row.episodeNumber,
502+
parentTitle: row.parentTitle,
503+
note: row.note,
504+
markedVia: row.markedVia,
505+
markedAt: row.markedAt.toISOString(),
506+
radarrTitleSlug: row.radarrTitleSlug,
507+
sonarrTitleSlug: row.sonarrTitleSlug,
508+
user: {
509+
id: row.user.id,
510+
name: row.user.name,
511+
email: row.user.email,
512+
image: row.user.image,
513+
},
514+
}))
515+
516+
return { success: true, marks, total, summary }
517+
} catch (error) {
518+
logger.error("Failed to get Discord marked media", error instanceof Error ? error : undefined)
519+
return {
520+
success: false,
521+
error: error instanceof Error ? error.message : "Failed to get marked media",
522+
marks: [],
523+
total: 0,
524+
summary: [],
525+
}
526+
}
527+
}

0 commit comments

Comments
 (0)