Skip to content
Merged
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
3 changes: 3 additions & 0 deletions apps/community/app/(board)/member/[id]/reputation/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
}),
Expand Down
2 changes: 2 additions & 0 deletions docs/administration/reputation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
124 changes: 110 additions & 14 deletions packages/db/src/reputation-repo.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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.')
Expand All @@ -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)
})

Expand All @@ -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)
})

Expand All @@ -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)
})
Expand All @@ -161,15 +179,21 @@ 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 () => {
await seedPost(100)
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)

Expand Down Expand Up @@ -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()
})
Expand All @@ -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)
Expand Down Expand Up @@ -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)
})

Expand All @@ -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([])
},
)
})
10 changes: 10 additions & 0 deletions packages/db/src/reputation-repo.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
`),
Expand Down
5 changes: 3 additions & 2 deletions packages/reputation/src/service.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
})
Expand Down
4 changes: 3 additions & 1 deletion packages/reputation/src/service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ForbiddenError, ValidationError } from '@meith/core'
import { ForbiddenError, type ThreadAudience, ValidationError } from '@meith/core'
import { msg } from '@meith/i18n'

import {
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/reputation/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ThreadAudience } from '@meith/core'

export const COMMENT_MAX = 500

export const RATING_VALUES = [1, 0, -1] as const
Expand Down Expand Up @@ -60,6 +62,7 @@ export interface ReputationRepository {
withdraw(input: { readonly ratingId: number; readonly givenByUserId: number }): Promise<boolean>

list(input: {
readonly audience: ThreadAudience
readonly userId: number
readonly limit: number
readonly before?: number | undefined
Expand Down
Loading