diff --git a/apps/community/app/(board)/member/[id]/reputation/page.tsx b/apps/community/app/(board)/member/[id]/reputation/page.tsx index 856ae9f7e..02dc9c12b 100644 --- a/apps/community/app/(board)/member/[id]/reputation/page.tsx +++ b/apps/community/app/(board)/member/[id]/reputation/page.tsx @@ -52,9 +52,12 @@ export default async function ReputationPage({ const postId = Number.isInteger(ratedPost) && ratedPost > 0 ? ratedPost : null const _preferences = await getViewerPreferences() + const audience = await authorizer.threadAudience(actor) + const [summary, history, limits] = await Promise.all([ service.summary(id), service.history({ + audience, userId: id, ...(Number.isInteger(before) && before > 0 ? { before } : {}), }), diff --git a/docs/administration/reputation.md b/docs/administration/reputation.md index 4097581ad..abf0def2a 100644 --- a/docs/administration/reputation.md +++ b/docs/administration/reputation.md @@ -23,3 +23,5 @@ Ratings are −1, 0 or +1 without group multipliers. Comments accept up to 500 c The member needs **Can give reputation**, sufficient posts and a remaining daily allowance. Self-ratings are blocked. [Group allowances](groups.md) combine using the highest value; zero is unlimited. The day resets at midnight UTC. Concurrent ratings from one member share the same allowance: the database serializes the daily check and write, while updating an existing rating remains allowed at the cap. Members can withdraw their own ratings. Totals update automatically, including after account merges. Check promotion and achievement rules before changing reputation policy. + +Reputation history shows post ratings only when the viewer can read the post's current thread, including own-thread-only restrictions. Held or deleted posts and threads do not expose rating comments or links here. Profile ratings and aggregate totals remain visible. Custom reputation repositories receive a required `ThreadAudience` in `list`; apply it before pagination so hidden ratings do not consume a page slot. diff --git a/packages/db/src/reputation-repo.test.ts b/packages/db/src/reputation-repo.test.ts index 94e6f88d7..319754506 100644 --- a/packages/db/src/reputation-repo.test.ts +++ b/packages/db/src/reputation-repo.test.ts @@ -1,6 +1,8 @@ import { sql } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { unrestrictedAudience } from '@meith/core' + import type { Database } from './client' import { createTestDb, type TestDb } from './pglite.fixture' import { PostgresReputationRepository } from './reputation-repo' @@ -109,7 +111,11 @@ describe('the cached total', () => { it('goes to zero rather than null when the last rating is withdrawn', async () => { await give({ points: 1 }) - const [row] = await repo.list({ userId: TARGET, limit: 10 }) + const [row] = await repo.list({ + audience: unrestrictedAudience([FORUM]), + userId: TARGET, + limit: 10, + }) expect(await repo.withdraw({ ratingId: row!.id, givenByUserId: RATER })).toBe(true) expect(await cachedTotal()).toBe(0) @@ -121,7 +127,11 @@ describe('the uniqueness rules', () => { await give({ points: 1, comment: 'First thought.' }) await give({ points: -1, comment: 'Second thought.' }) - const rows = await repo.list({ userId: TARGET, limit: 10 }) + const rows = await repo.list({ + audience: unrestrictedAudience([FORUM]), + userId: TARGET, + limit: 10, + }) expect(rows).toHaveLength(1) expect(rows[0]?.points).toBe(-1) expect(rows[0]?.comment).toBe('Second thought.') @@ -133,7 +143,9 @@ describe('the uniqueness rules', () => { await give({ postId: null, points: 1 }) await give({ postId: 100, points: 1 }) - expect(await repo.list({ userId: TARGET, limit: 10 })).toHaveLength(2) + expect( + await repo.list({ audience: unrestrictedAudience([FORUM]), userId: TARGET, limit: 10 }), + ).toHaveLength(2) expect(await cachedTotal()).toBe(2) }) @@ -142,7 +154,9 @@ describe('the uniqueness rules', () => { await db.execute(sql`update posts set author_user_id = ${OTHER} where id = 100`) expect(await give({ postId: 100 })).toBe('invalid-post') - expect(await repo.list({ userId: TARGET, limit: 10 })).toHaveLength(0) + expect( + await repo.list({ audience: unrestrictedAudience([FORUM]), userId: TARGET, limit: 10 }), + ).toHaveLength(0) expect(await cachedTotal()).toBe(0) }) @@ -151,7 +165,11 @@ describe('the uniqueness rules', () => { await give({ postId: 100, points: 1 }) await give({ postId: 100, points: 0 }) - const rows = await repo.list({ userId: TARGET, limit: 10 }) + const rows = await repo.list({ + audience: unrestrictedAudience([FORUM]), + userId: TARGET, + limit: 10, + }) expect(rows).toHaveLength(1) expect(rows[0]?.points).toBe(0) }) @@ -161,7 +179,9 @@ describe('the uniqueness rules', () => { await give({ postId: 100, givenByUserId: RATER }) await give({ postId: 100, givenByUserId: OTHER }) - expect(await repo.list({ userId: TARGET, limit: 10 })).toHaveLength(2) + expect( + await repo.list({ audience: unrestrictedAudience([FORUM]), userId: TARGET, limit: 10 }), + ).toHaveLength(2) }) it('carries the thread id for a post rating, and nothing for a profile one', async () => { @@ -169,7 +189,11 @@ describe('the uniqueness rules', () => { await give({ postId: 100 }) await give({ postId: null, givenByUserId: OTHER }) - const rows = await repo.list({ userId: TARGET, limit: 10 }) + const rows = await repo.list({ + audience: unrestrictedAudience([FORUM]), + userId: TARGET, + limit: 10, + }) const post = rows.find((row) => row.postId !== null) const profile = rows.find((row) => row.postId === null) @@ -236,11 +260,18 @@ describe('reads', () => { it('names the giver, and says so when the account is gone', async () => { await give() - expect((await repo.list({ userId: TARGET, limit: 10 }))[0]?.givenByUsername).toBe('bob') + expect( + (await repo.list({ audience: unrestrictedAudience([FORUM]), userId: TARGET, limit: 10 }))[0] + ?.givenByUsername, + ).toBe('bob') await db.execute(sql`delete from users where id = ${RATER}`) - const rows = await repo.list({ userId: TARGET, limit: 10 }) + const rows = await repo.list({ + audience: unrestrictedAudience([FORUM]), + userId: TARGET, + limit: 10, + }) expect(rows).toHaveLength(1) expect(rows[0]?.givenByUsername).toBeNull() }) @@ -249,8 +280,17 @@ describe('reads', () => { await give({ givenByUserId: RATER }) await give({ givenByUserId: OTHER }) - const page = await repo.list({ userId: TARGET, limit: 1 }) - const next = await repo.list({ userId: TARGET, limit: 1, before: page[0]!.id }) + const page = await repo.list({ + audience: unrestrictedAudience([FORUM]), + userId: TARGET, + limit: 1, + }) + const next = await repo.list({ + audience: unrestrictedAudience([FORUM]), + userId: TARGET, + limit: 1, + before: page[0]!.id, + }) expect(next).toHaveLength(1) expect(next[0]!.id).toBeLessThan(page[0]!.id) @@ -307,10 +347,16 @@ describe('the thread page’s batch reads', () => { describe('withdrawing', () => { it('is scoped to the giver in the query', async () => { await give() - const [row] = await repo.list({ userId: TARGET, limit: 10 }) + const [row] = await repo.list({ + audience: unrestrictedAudience([FORUM]), + userId: TARGET, + limit: 10, + }) expect(await repo.withdraw({ ratingId: row!.id, givenByUserId: OTHER })).toBe(false) - expect(await repo.list({ userId: TARGET, limit: 10 })).toHaveLength(1) + expect( + await repo.list({ audience: unrestrictedAudience([FORUM]), userId: TARGET, limit: 10 }), + ).toHaveLength(1) expect(await cachedTotal()).toBe(1) }) @@ -319,6 +365,56 @@ describe('withdrawing', () => { await give({ postId: 100 }) await db.execute(sql`delete from posts where id = 100`) - expect(await repo.list({ userId: TARGET, limit: 10 })).toEqual([]) + expect( + await repo.list({ audience: unrestrictedAudience([FORUM]), userId: TARGET, limit: 10 }), + ).toEqual([]) }) }) + +describe('history visibility', () => { + it('keeps profile ratings but filters post comments and links outside the audience', async () => { + await seedPost(100) + await give({ postId: null, comment: 'Public profile' }) + await give({ postId: 100, comment: 'Private discussion' }) + + const rows = await repo.list({ userId: TARGET, limit: 1, audience: unrestrictedAudience([]) }) + expect(rows.map((row) => row.comment)).toEqual(['Public profile']) + expect(rows[0]?.postId).toBeNull() + expect(await repo.summary(TARGET)).toMatchObject({ total: 2 }) + }) + + it('checks the live thread forum and own-thread audience rather than the post forum', async () => { + await seedPost(100) + await give({ postId: 100 }) + const audience = { forumIds: [FORUM], ownThreadsOnlyForumIds: [FORUM], viewerUserId: RATER } + expect(await repo.list({ userId: TARGET, limit: 10, audience })).toEqual([]) + expect( + await repo.list({ + userId: TARGET, + limit: 10, + audience: { ...audience, viewerUserId: TARGET }, + }), + ).toHaveLength(1) + await db.execute( + sql`insert into forums (id, type, title, slug, path) values (11, 'forum', 'Private', 'private', '11')`, + ) + await db.execute(sql`update threads set forum_id = 11 where id = ${THREAD}`) + expect( + await repo.list({ userId: TARGET, limit: 10, audience: unrestrictedAudience([FORUM]) }), + ).toEqual([]) + }) + + it.each(['unapproved', 'deleted'])( + 'hides ratings on %s posts and threads', + async (visibility) => { + await seedPost(100) + await give({ postId: 100 }) + const input = { userId: TARGET, limit: 10, audience: unrestrictedAudience([FORUM]) } + await db.execute(sql`update posts set visibility = ${visibility} where id = 100`) + expect(await repo.list(input)).toEqual([]) + await db.execute(sql`update posts set visibility = 'visible' where id = 100`) + await db.execute(sql`update threads set visibility = ${visibility} where id = ${THREAD}`) + expect(await repo.list(input)).toEqual([]) + }, + ) +}) diff --git a/packages/db/src/reputation-repo.ts b/packages/db/src/reputation-repo.ts index 616345522..3d69848f1 100644 --- a/packages/db/src/reputation-repo.ts +++ b/packages/db/src/reputation-repo.ts @@ -1,6 +1,7 @@ import type { SQLWrapper } from 'drizzle-orm' import { sql } from 'drizzle-orm' +import { PUBLIC_CONTENT, type ThreadAudience } from '@meith/core' import type { ReputationRepository, ReputationRow, @@ -11,6 +12,8 @@ import type { import type { Database } from './client' import { resultRows } from './result-rows' import { toDate } from './row-values' +import { inAudience } from './thread-audience' +import { visibleIn } from './visibility' interface RawRow { id: number @@ -174,6 +177,7 @@ export class PostgresReputationRepository implements ReputationRepository { } async list(input: { + readonly audience: ThreadAudience readonly userId: number readonly limit: number readonly before?: number | undefined @@ -183,7 +187,13 @@ export class PostgresReputationRepository implements ReputationRepository { const rows = resultRows( await this.db.execute(sql` ${SELECT_ROW} + left join threads t on t.id = p.thread_id where r.user_id = ${input.userId} ${before} + and (r.post_id is null or ( + ${inAudience(sql`t.forum_id`, sql`t.author_user_id`, input.audience)} + and ${visibleIn(sql`t.visibility`, PUBLIC_CONTENT)} + and ${visibleIn(sql`p.visibility`, PUBLIC_CONTENT)} + )) order by r.id desc limit ${input.limit} `), diff --git a/packages/reputation/src/service.test.ts b/packages/reputation/src/service.test.ts index 247e7086c..5933a246f 100644 --- a/packages/reputation/src/service.test.ts +++ b/packages/reputation/src/service.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { ForbiddenError, ValidationError } from '@meith/core' +import { ForbiddenError, unrestrictedAudience, ValidationError } from '@meith/core' import { ReputationService } from './service' import type { @@ -238,11 +238,12 @@ describe('history', () => { updatedAt: new Date(), })) - const first = await service.history({ userId: TARGET }) + const first = await service.history({ audience: unrestrictedAudience([]), userId: TARGET }) expect(first.rows).toHaveLength(25) expect(first.nextBefore).toBe(first.rows[24]?.id) const second = await service.history({ + audience: unrestrictedAudience([]), userId: TARGET, before: first.nextBefore as number, }) diff --git a/packages/reputation/src/service.ts b/packages/reputation/src/service.ts index 8944d6411..ab2747808 100644 --- a/packages/reputation/src/service.ts +++ b/packages/reputation/src/service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError, ValidationError } from '@meith/core' +import { ForbiddenError, type ThreadAudience, ValidationError } from '@meith/core' import { msg } from '@meith/i18n' import { @@ -83,10 +83,12 @@ export class ReputationService { } async history(input: { + readonly audience: ThreadAudience readonly userId: number readonly before?: number | undefined }): Promise<{ rows: readonly ReputationRow[]; nextBefore: number | null }> { const rows = await this.repository.list({ + audience: input.audience, userId: input.userId, limit: REPUTATION_PAGE_SIZE + 1, before: input.before, diff --git a/packages/reputation/src/types.ts b/packages/reputation/src/types.ts index 784312abc..aa42c758c 100644 --- a/packages/reputation/src/types.ts +++ b/packages/reputation/src/types.ts @@ -1,3 +1,5 @@ +import type { ThreadAudience } from '@meith/core' + export const COMMENT_MAX = 500 export const RATING_VALUES = [1, 0, -1] as const @@ -60,6 +62,7 @@ export interface ReputationRepository { withdraw(input: { readonly ratingId: number; readonly givenByUserId: number }): Promise list(input: { + readonly audience: ThreadAudience readonly userId: number readonly limit: number readonly before?: number | undefined