From e8cc8c45ada6ce20750cd09b9a8325a017db271f Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 22 Sep 2026 17:02:13 +0800 Subject: [PATCH 1/3] feat(ai): add Jev decision model and two-stage comment moderation --- apps/admin/src/api/ai.ts | 4 +- .../comments/components/CommentListItem.tsx | 10 + .../components/CommentsRouteViewContent.tsx | 6 +- .../settings/components/ai/AIConfigEditor.tsx | 39 +- .../components/ai/AIProviderDrawer.tsx | 111 +- .../settings/config/aiProviderPresets.ts | 10 + apps/admin/src/features/settings/constants.ts | 1 + .../src/features/settings/types/settings.ts | 6 +- .../features/settings/utils/settings.test.ts | 31 + .../src/features/settings/utils/settings.ts | 14 +- apps/admin/src/i18n/resources/en-US.ts | 13 + apps/admin/src/i18n/resources/zh-CN.ts | 10 + apps/admin/src/models/comment.ts | 8 +- .../migrations/0040_comment_moderation.sql | 5 + .../migrations/meta/0040_snapshot.json | 7257 +++++++++++++++++ .../database/migrations/meta/_journal.json | 9 +- apps/core/src/modules/ai/ai.controller.ts | 58 +- apps/core/src/modules/ai/ai.service.ts | 24 + apps/core/src/modules/ai/ai.types.ts | 4 +- apps/core/src/modules/ai/decision/typesafe.ts | 100 + .../modules/ai/runtime/ai-provider.factory.ts | 2 + .../modules/ai/runtime/pi-runtime.adapter.ts | 2 +- .../src/modules/comment/comment-decision.ts | 87 + .../src/modules/comment/comment.controller.ts | 31 +- .../comment/comment.lifecycle.service.ts | 178 +- .../src/modules/comment/comment.repository.ts | 109 +- .../src/modules/comment/comment.service.ts | 106 +- .../modules/comment/comment.spam-filter.ts | 84 +- .../core/src/modules/comment/comment.types.ts | 3 + .../core/src/modules/comment/comment.views.ts | 3 + .../src/modules/configs/configs.default.ts | 3 + .../src/modules/configs/configs.schema.ts | 17 + .../src/modules/configs/configs.service.ts | 28 + .../test/helper/comment-service-fixture.ts | 2 + .../modules/comment/comment-decision.spec.ts | 223 + .../comment/comment-moderation.e2e-spec.ts | 275 + .../comment-moderation.repository.spec.ts | 117 + .../comment/comment.controller.spec.ts | 4 +- .../comment.spam-filter.faux.e2e.spec.ts | 7 +- docs/features/comment-decision-review.md | 19 + packages/api-client/controllers/comment.ts | 6 + packages/api-client/models/comment.ts | 6 + packages/db-schema/src/schema/content.ts | 3 + 43 files changed, 8841 insertions(+), 194 deletions(-) create mode 100644 apps/core/src/database/migrations/0040_comment_moderation.sql create mode 100644 apps/core/src/database/migrations/meta/0040_snapshot.json create mode 100644 apps/core/src/modules/ai/decision/typesafe.ts create mode 100644 apps/core/src/modules/comment/comment-decision.ts create mode 100644 apps/core/test/src/modules/comment/comment-decision.spec.ts create mode 100644 apps/core/test/src/modules/comment/comment-moderation.e2e-spec.ts create mode 100644 apps/core/test/src/modules/comment/comment-moderation.repository.spec.ts create mode 100644 docs/features/comment-decision-review.md diff --git a/apps/admin/src/api/ai.ts b/apps/admin/src/api/ai.ts index abdabf6f0e7..b9404cb4f85 100644 --- a/apps/admin/src/api/ai.ts +++ b/apps/admin/src/api/ai.ts @@ -347,7 +347,9 @@ export function getModels() { return getModelsByCapability('text') } -export function getModelsByCapability(capability: 'image' | 'speech' | 'text') { +export function getModelsByCapability( + capability: 'decision' | 'image' | 'speech' | 'text', +) { return getJson('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/ai/models', { capability }) } diff --git a/apps/admin/src/features/comments/components/CommentListItem.tsx b/apps/admin/src/features/comments/components/CommentListItem.tsx index d625f113c07..511d274db97 100644 --- a/apps/admin/src/features/comments/components/CommentListItem.tsx +++ b/apps/admin/src/features/comments/components/CommentListItem.tsx @@ -204,6 +204,16 @@ export function CommentListItem(props: CommentListItemProps) { ) : null} + {props.comment.moderationStatus === 'pending' || + props.comment.moderationStatus === 'manual' ? ( + + {t( + props.comment.moderationStatus === 'manual' + ? 'comments.badge.manualReview' + : 'comments.badge.reviewPending', + )} + + ) : null} {props.comment.editedAt ? ( { // Mark as read when opening — only fires for genuinely unread rows so // we don't spam the server when navigating among already-read items. - if (comment.state === CommentState.Unread) { + if ( + comment.state === CommentState.Unread && + comment.moderationStatus !== 'pending' && + comment.moderationStatus !== 'manual' + ) { markReadOnOpenMutation.mutate(comment.id) } const qs = searchParams.toString() diff --git a/apps/admin/src/features/settings/components/ai/AIConfigEditor.tsx b/apps/admin/src/features/settings/components/ai/AIConfigEditor.tsx index 54abe270656..7eb5d6e9547 100644 --- a/apps/admin/src/features/settings/components/ai/AIConfigEditor.tsx +++ b/apps/admin/src/features/settings/components/ai/AIConfigEditor.tsx @@ -56,7 +56,9 @@ const PRESET_NAME_KEYS: Partial> = { xai: 'settings.ai.preset.name.xai', } -async function getProviderModels(capability: 'image' | 'speech' | 'text') { +async function getProviderModels( + capability: 'decision' | 'image' | 'speech' | 'text', +) { const response = await getModelsByCapability(capability) const entries: Array<[string, AIProviderModel[]]> = response.map( (provider) => [provider.providerId, provider.models ?? []], @@ -99,6 +101,13 @@ export function AIConfigEditor(props: { queryKey: [...props.modelCacheKey, 'image'], staleTime: 24 * 60 * 60 * 1000, }) + const decisionModelsQuery = useQuery({ + enabled: providers.some((p) => p.enabled && p.capabilities?.decision), + queryFn: () => getProviderModels('decision'), + queryKey: [...props.modelCacheKey, 'decision'], + staleTime: 24 * 60 * 60 * 1000, + }) + const decisionModels = decisionModelsQuery.data ?? {} const providerModels = modelsQuery.data ?? {} const speechProviderModels = speechModelsQuery.data ?? {} const imageProviderModels = imageModelsQuery.data ?? {} @@ -128,6 +137,7 @@ export function AIConfigEditor(props: { const deleteProvider = (id: string) => { const references = [ + props.value.decisionModel, props.value.summaryModel, props.value.writerModel, props.value.commentReviewModel, @@ -228,6 +238,18 @@ export function AIConfigEditor(props: { )} + + updateConfig({ decisionModel })} + /> + + editingId ? updateProvider(editingId, patch) : undefined } onClose={() => setEditingId(null)} provider={editingProvider} providerModels={ - editingProvider ? (providerModels[editingProvider.id] ?? []) : [] + editingProvider + ? ((editingProvider.capabilities?.decision + ? decisionModels + : providerModels)[editingProvider.id] ?? []) + : [] } /> @@ -613,6 +643,9 @@ function ProviderRow(props: { const { t } = useI18n() const provider = props.provider const capabilities = [ + provider.capabilities?.decision + ? t('settings.ai.capability.decision') + : null, (provider.capabilities?.text ?? true) ? t('settings.ai.capability.text') : null, diff --git a/apps/admin/src/features/settings/components/ai/AIProviderDrawer.tsx b/apps/admin/src/features/settings/components/ai/AIProviderDrawer.tsx index 0188aae6cf5..5e1ef36e05b 100644 --- a/apps/admin/src/features/settings/components/ai/AIProviderDrawer.tsx +++ b/apps/admin/src/features/settings/components/ai/AIProviderDrawer.tsx @@ -54,6 +54,7 @@ export function AIProviderDrawer(props: { const [fetching, setFetching] = useState(false) const [testing, setTesting] = useState(false) const provider = props.provider + const decisionEnabled = provider?.type === 'typesafe' const textEnabled = provider?.capabilities?.text ?? true const matchedPreset = provider ? findAIProviderPreset(provider) : undefined const isGoogleVertex = provider?.type === 'google-vertex' @@ -85,7 +86,8 @@ export function AIProviderDrawer(props: { () => mergeModelOptions(props.providerModels, registryModels), [props.providerModels, registryModels], ) - const modelsDisabled = piProviderId === null && modelOptions.length === 0 + const modelsDisabled = + !decisionEnabled && piProviderId === null && modelOptions.length === 0 const modelMatch = useMemo( () => provider @@ -94,7 +96,7 @@ export function AIProviderDrawer(props: { [registryModels, provider], ) const showCustomTokenFields = Boolean( - provider && provider.defaultModel.trim() && !modelMatch, + provider && !decisionEnabled && provider.defaultModel.trim() && !modelMatch, ) const refreshModels = async () => { @@ -163,7 +165,7 @@ export function AIProviderDrawer(props: { footer={ provider ? ( <> - {textEnabled ? ( + {textEnabled || decisionEnabled ? ( <> + + + ) +} + +function LegRows({ + legs, + meta, + onMove, + onRemove, + onRename, +}: Omit) { + return ( +
    + {legs.map((leg, index) => { + const info = meta?.[index] + const details = [ + info?.startTimeMs ? dateFormat.format(info.startTimeMs) : null, + typeof info?.distanceMeters === 'number' + ? `${(info.distanceMeters / 1000).toFixed(1)} km` + : null, + ].filter(Boolean) + return ( +
  1. + + {index + 1} + +
    + onRename(index, value)} + value={leg.title} + /> +
    + + {details.join(' · ')} + + + + +
  2. + ) + })} +
+ ) +} diff --git a/apps/admin/src/vendor/rich-editor/extensions/map/gps-compress.test.ts b/apps/admin/src/vendor/rich-editor/extensions/map/gps-compress.test.ts new file mode 100644 index 00000000000..d7a9f97ee6e --- /dev/null +++ b/apps/admin/src/vendor/rich-editor/extensions/map/gps-compress.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' + +import type { GpxPoint } from './gps-compress' +import { buildLegsTrackJson, buildTrackJson } from './gps-compress' + +const T0 = Date.UTC(2026, 9, 2, 0, 0, 0) + +function walk( + startLat: number, + count: number, + startMs: number, + stepMs = 10_000, +): GpxPoint[] { + return Array.from({ length: count }, (_, i) => ({ + ele: null, + lat: startLat + i * 0.0005, + lon: 135, + timeMs: startMs + i * stepMs, + })) +} + +describe('buildLegsTrackJson', () => { + const day1 = [...walk(35, 10, T0), ...walk(35.2, 10, T0 + 3 * 3_600_000)] + const day2 = walk(34.7, 10, T0 + 86_400_000) + const track = buildLegsTrackJson( + [ + { points: day1, timezoneOffsetMinutes: 540, title: 'Day 1' }, + { points: day2, title: 'Day 2' }, + ], + 'Trip', + { sampleTarget: null }, + ) + + it('indexes legs over contiguous segment ranges', () => { + expect(track.segments).toHaveLength(3) + expect(track.legs?.map((leg) => leg.segments)).toEqual([ + [0, 2], + [2, 3], + ]) + expect(track.legs?.map((leg) => leg.title)).toEqual(['Day 1', 'Day 2']) + }) + + it('keeps points equal to the flattened segments', () => { + expect(track.points).toEqual(track.segments!.flat()) + }) + + it('sums distance per segment without bridging gaps', () => { + const legSum = track.legs!.reduce( + (sum, leg) => sum + (leg.distanceMeters ?? 0), + 0, + ) + expect(Math.abs(track.distanceMeters! - legSum)).toBeLessThanOrEqual(1) + expect(track.distanceMeters!).toBeLessThan(5_000) + }) + + it('spans times across legs and keeps the first timezone', () => { + expect(track.startTimeMs).toBe(T0) + expect(track.endTimeMs).toBe(T0 + 86_400_000 + 9 * 10_000) + expect(track.timezoneOffsetMinutes).toBe(540) + }) + + it('omits legs for a single file', () => { + const single = buildTrackJson(day1, 'Solo', { sampleTarget: null }) + expect(single.legs).toBeUndefined() + expect(single.segments).toHaveLength(2) + }) +}) diff --git a/apps/admin/src/vendor/rich-editor/extensions/map/gps-compress.ts b/apps/admin/src/vendor/rich-editor/extensions/map/gps-compress.ts index 5d547151b58..7a50e0e934f 100644 --- a/apps/admin/src/vendor/rich-editor/extensions/map/gps-compress.ts +++ b/apps/admin/src/vendor/rich-editor/extensions/map/gps-compress.ts @@ -1,4 +1,9 @@ -import type { MapTrackData, MapTrackStop } from '@mx-space/editor' +import type { + MapTrackData, + MapTrackLeg, + MapTrackPointTuple, + MapTrackStop, +} from '@mx-space/editor' export interface GpxPoint { ele: number | null @@ -28,6 +33,12 @@ export interface BuildTrackJsonOptions { timezoneOffsetMinutes?: number | null } +export interface GpxLegInput { + points: GpxPoint[] + timezoneOffsetMinutes?: number | null + title: string +} + export function parseGpx(text: string): GpxPoint[] { const points: GpxPoint[] = [] const trkptRe = /]*)>(.*?)<\/trkpt>/gs @@ -275,36 +286,118 @@ export function getBounds(points: GpxPoint[]): Bounds { } } +export function orderTrackPoints(points: GpxPoint[]): GpxPoint[] { + if (points.length < 2) return points + if (!points.every((point) => Number.isFinite(point.timeMs))) return points + for (let i = 1; i < points.length; i++) { + if (points[i]!.timeMs! < points[i - 1]!.timeMs!) { + return [...points].sort((a, b) => a.timeMs! - b.timeMs!) + } + } + return points +} + +export function splitTrackSegments( + points: GpxPoint[], + { breakDistanceMeters = 3_000, breakGapSec = 1_800 } = {}, +): GpxPoint[][] { + if (points.length === 0) return [] + const segments: GpxPoint[][] = [[points[0]!]] + for (let i = 1; i < points.length; i++) { + const previous = points[i - 1]! + const current = points[i]! + const dtSec = + Number.isFinite(previous.timeMs) && Number.isFinite(current.timeMs) + ? (current.timeMs! - previous.timeMs!) / 1000 + : 0 + const shouldBreak = + dtSec < 0 || + (dtSec >= breakGapSec && + distanceMeters(previous, current) >= breakDistanceMeters) + if (shouldBreak) segments.push([current]) + else segments.at(-1)!.push(current) + } + return segments +} + export function buildTrackJson( points: GpxPoint[], title: string, options: BuildTrackJsonOptions = {}, ): MapTrackData { - const { - sampleTarget = 450, - timezoneOffsetMinutes, - detectStopsOptions, - } = options - const sampled = - sampleTarget && points.length > sampleTarget - ? simplifyToTarget(points, sampleTarget) - : points - const stops = detectStops(points, detectStopsOptions) - const startTimeMs = firstFinite(points.map((p) => p.timeMs ?? null)) - const endTimeMs = lastFinite(points.map((p) => p.timeMs ?? null)) + return buildLegsTrackJson( + [{ points, timezoneOffsetMinutes: options.timezoneOffsetMinutes, title }], + title, + options, + ) +} + +export function buildLegsTrackJson( + inputs: GpxLegInput[], + title: string, + options: Omit = {}, +): MapTrackData { + const { sampleTarget = 450, detectStopsOptions } = options + const segments: GpxPoint[][] = [] + const stops: MapTrackStop[] = [] + const legs: MapTrackLeg[] = [] + let originalCount = 0 + let distance = 0 + + for (const input of inputs) { + const ordered = orderTrackPoints(input.points) + const legSegments = splitTrackSegments(ordered) + const legDistance = legSegments.reduce( + (sum, segment) => sum + totalDistance(segment), + 0, + ) + const startTimeMs = firstFinite(ordered.map((p) => p.timeMs ?? null)) + const endTimeMs = lastFinite(ordered.map((p) => p.timeMs ?? null)) + legs.push({ + distanceMeters: Math.round(legDistance), + ...(typeof endTimeMs === 'number' && { endTimeMs }), + segments: [segments.length, segments.length + legSegments.length], + ...(typeof startTimeMs === 'number' && { startTimeMs }), + title: input.title, + }) + segments.push(...legSegments) + stops.push(...detectStops(ordered, detectStopsOptions)) + originalCount += ordered.length + distance += legDistance + } + + const sampledSegments = + sampleTarget && originalCount > sampleTarget + ? segments.map((segment) => + simplifyToTarget( + segment, + Math.max( + 2, + Math.round((sampleTarget * segment.length) / originalCount), + ), + ), + ) + : segments + const sampled = sampledSegments.flat() + const startTimes = legs.flatMap((leg) => leg.startTimeMs ?? []) + const endTimes = legs.flatMap((leg) => leg.endTimeMs ?? []) + const timezoneOffsetMinutes = inputs.find( + (input) => typeof input.timezoneOffsetMinutes === 'number', + )?.timezoneOffsetMinutes + return { bounds: getBounds(sampled), - distanceMeters: Math.round(totalDistance(points)), - ...(typeof endTimeMs === 'number' && { endTimeMs }), - originalCount: points.length, - points: sampled.map((point) => [ - round(point.lat, 7), - round(point.lon, 7), - point.ele === null ? null : round(point.ele, 1), - ]) as MapTrackData['points'], + distanceMeters: Math.round(distance), + ...(endTimes.length > 0 && { endTimeMs: Math.max(...endTimes) }), + ...(legs.length > 1 && { legs }), + originalCount, + points: sampled.map(toTuple), sampledCount: sampled.length, - ...(typeof startTimeMs === 'number' && { startTimeMs }), - ...(stops.length > 0 && { stops }), + segments: sampledSegments.map((segment) => segment.map(toTuple)), + ...(startTimes.length > 0 && { startTimeMs: Math.min(...startTimes) }), + ...(stops.length > 0 && { + stops: stops.sort((a, b) => (a.time ?? '').localeCompare(b.time ?? '')), + }), ...(typeof timezoneOffsetMinutes === 'number' && { timezoneOffsetMinutes, }), @@ -313,6 +406,14 @@ export function buildTrackJson( } } +function toTuple(point: GpxPoint): MapTrackPointTuple { + return [ + round(point.lat, 7), + round(point.lon, 7), + point.ele === null ? null : round(point.ele, 1), + ] +} + export function isGpxFile(file: { name: string; type: string }): boolean { return /\.gpx$/i.test(file.name) || file.type === 'application/gpx+xml' } @@ -334,7 +435,16 @@ export function buildTrackFile( points: GpxPoint[], options: BuildTrackJsonOptions = {}, ): { file: File; trackData: MapTrackData } { - const trackData = buildTrackJson(points, baseFileName, options) + return trackDataToFile( + baseFileName, + buildTrackJson(points, baseFileName, options), + ) +} + +export function trackDataToFile( + baseFileName: string, + trackData: MapTrackData, +): { file: File; trackData: MapTrackData } { const jsonBlob = new Blob([JSON.stringify(trackData)], { type: 'application/json', }) diff --git a/apps/admin/src/vendor/rich-editor/extensions/map/map-layers.ts b/apps/admin/src/vendor/rich-editor/extensions/map/map-layers.ts index a287076ad06..c781d51c7e1 100644 --- a/apps/admin/src/vendor/rich-editor/extensions/map/map-layers.ts +++ b/apps/admin/src/vendor/rich-editor/extensions/map/map-layers.ts @@ -110,11 +110,14 @@ export function addPoiLayers(map: MapLibreMap, colors: LayerColors) { export function setRouteData( map: MapLibreMap, - coords: Array<[number, number]>, + segments: Array>, ) { const src = map.getSource(ROUTE_SOURCE) as GeoJSONSource | undefined src?.setData({ - geometry: { coordinates: coords, type: 'LineString' }, + geometry: { + coordinates: segments.filter((segment) => segment.length >= 2), + type: 'MultiLineString', + }, properties: {}, type: 'Feature', }) diff --git a/apps/admin/src/vendor/rich-editor/extensions/map/parse-track-file.ts b/apps/admin/src/vendor/rich-editor/extensions/map/parse-track-file.ts new file mode 100644 index 00000000000..4818f988588 --- /dev/null +++ b/apps/admin/src/vendor/rich-editor/extensions/map/parse-track-file.ts @@ -0,0 +1,38 @@ +import type { GpxPoint } from './gps-compress' +import { isGpxFile, readGpxFile } from './gps-compress' + +export interface GpxLeg { + baseName: string + id: string + points: GpxPoint[] + startTimeMs: number + tzOffsetMinutes: number | null +} + +export type RawPick = + { legs: GpxLeg[]; type: 'gpx' } | { file: File; type: 'json' } + +type ParsedFile = { file: File; type: 'json' } | { leg: GpxLeg; type: 'gpx' } + +export async function parseTrackFile(file: File): Promise { + let source = file + if (!isGpxFile(file)) { + const text = await file.text() + if (!/ Number.isFinite(point.timeMs))?.timeMs ?? + Number.POSITIVE_INFINITY + return { + leg: { + baseName: file.name.replace(/\.(gpx|xml)$/i, ''), + id: crypto.randomUUID(), + points, + startTimeMs, + tzOffsetMinutes, + }, + type: 'gpx', + } +} diff --git a/packages/editor/src/core/nodes/map.ts b/packages/editor/src/core/nodes/map.ts index b1e948a8aeb..0701c15ad69 100644 --- a/packages/editor/src/core/nodes/map.ts +++ b/packages/editor/src/core/nodes/map.ts @@ -2,8 +2,7 @@ import type { MxBlockProjection } from '../types' import { serializeLiteXmlFallbackNode } from './litexml' export type MapTrackPointTuple = - | [number, number] - | [number, number, number | null] + [number, number] | [number, number, number | null] export interface MapTrackStop { durationSec: number @@ -21,13 +20,23 @@ export interface MapTrackBounds { minLon: number } +export interface MapTrackLeg { + distanceMeters?: number + endTimeMs?: number + segments: [from: number, to: number] + startTimeMs?: number + title: string +} + export interface MapTrackData { bounds?: MapTrackBounds distanceMeters?: number endTimeMs?: number + legs?: MapTrackLeg[] originalCount?: number points: MapTrackPointTuple[] sampledCount?: number + segments?: MapTrackPointTuple[][] startTimeMs?: number stops?: MapTrackStop[] timezoneOffsetMinutes?: number From f3628a2bbc466be657ab76b00e6793857de5b875 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 23 Sep 2026 19:15:22 +0800 Subject: [PATCH 3/3] fix(comment): keep trusted reader comments public under audit mode --- .../src/modules/comment/comment-decision.ts | 3 +- .../src/modules/comment/comment.repository.ts | 11 +++++++- .../modules/comment/comment-decision.spec.ts | 6 ++++ .../comment-moderation.repository.spec.ts | 28 +++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/core/src/modules/comment/comment-decision.ts b/apps/core/src/modules/comment/comment-decision.ts index 759070a88b9..cac27754c1d 100644 --- a/apps/core/src/modules/comment/comment-decision.ts +++ b/apps/core/src/modules/comment/comment-decision.ts @@ -68,6 +68,7 @@ export function commentSubmissionStatus( state: number moderationStatus?: string | null isDeleted?: boolean + readerId?: string | null }, requiresAudit: boolean, ): 'published' | 'pending' | 'rejected' { @@ -80,7 +81,7 @@ export function commentSubmissionStatus( if ( comment.moderationStatus === 'pending' || comment.moderationStatus === 'manual' || - (requiresAudit && comment.state !== 1) + (requiresAudit && comment.state !== 1 && !comment.readerId) ) return 'pending' return 'published' diff --git a/apps/core/src/modules/comment/comment.repository.ts b/apps/core/src/modules/comment/comment.repository.ts index bf839f4eec2..c683f7fa071 100644 --- a/apps/core/src/modules/comment/comment.repository.ts +++ b/apps/core/src/modules/comment/comment.repository.ts @@ -7,6 +7,7 @@ import { gte, ilike, inArray, + isNotNull, isNull, lte, ne, @@ -1103,7 +1104,15 @@ export class CommentRepository extends BaseRepository { )!, ] if (commentShouldAudit) { - filters.push(eq(comments.state, CommentState.Read)) + filters.push( + or( + eq(comments.state, CommentState.Read), + and( + eq(comments.state, CommentState.Unread), + isNotNull(comments.readerId), + ), + )!, + ) } else { filters.push( inArray(comments.state, [CommentState.Unread, CommentState.Read]), diff --git a/apps/core/test/src/modules/comment/comment-decision.spec.ts b/apps/core/test/src/modules/comment/comment-decision.spec.ts index 986e38ba359..47fe44072e8 100644 --- a/apps/core/test/src/modules/comment/comment-decision.spec.ts +++ b/apps/core/test/src/modules/comment/comment-decision.spec.ts @@ -83,6 +83,12 @@ describe('two-stage comment decisions', () => { false, ), ).toBe('published') + expect( + commentSubmissionStatus( + { state: 0, moderationStatus: 'approved', readerId: 'reader' }, + true, + ), + ).toBe('published') expect( commentSubmissionStatus({ state: 1, moderationStatus: 'pending' }, false), ).toBe('pending') diff --git a/apps/core/test/src/modules/comment/comment-moderation.repository.spec.ts b/apps/core/test/src/modules/comment/comment-moderation.repository.spec.ts index 2e457cf1357..318c3cf476b 100644 --- a/apps/core/test/src/modules/comment/comment-moderation.repository.spec.ts +++ b/apps/core/test/src/modules/comment/comment-moderation.repository.spec.ts @@ -7,6 +7,7 @@ import { import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { CollectionRefTypes } from '~/constants/db.constant' +import { readers } from '~/database/schema' import { CommentRepository } from '~/modules/comment/comment.repository' import { SnowflakeService } from '~/shared/id/snowflake.service' @@ -74,6 +75,33 @@ describe('durable comment moderation', () => { expect((await repository.findById(reply.id))?.state).toBe(2) }) + it('keeps approved reader comments public when human approval is required', async () => { + const refId = snowflake.nextId() + const readerId = snowflake.nextId() + await context.db.insert(readers).values({ id: readerId, name: 'Reader' }) + await repository.create({ + refType: CollectionRefTypes.Post, + refId, + text: 'reader', + moderationStatus: 'approved', + readerId, + }) + await repository.create({ + refType: CollectionRefTypes.Post, + refId, + text: 'guest', + moderationStatus: 'approved', + }) + const { data } = await repository.findRootThreadsByRef(refId, { + page: 1, + size: 10, + sort: 'newest', + isAuthenticated: false, + commentShouldAudit: true, + }) + expect(data.map((row) => row.text)).toEqual(['reader']) + }) + it('cannot overwrite manual decisions or edited text with stale model results', async () => { const input = { refType: CollectionRefTypes.Post,