refactor(articles): deprecate Article.nsfw; derive nsfwLevel floor from moderation records

Stop using Article.nsfw as an enforcement signal anywhere in the app.
The field stays in the DB schema for back-compat but is no longer
written or read. Fixes a latent bug where text-flagged articles were
bumped to nsfwLevel = R|X|XXX|Blocked (60) instead of just R.

updateArticleNsfwLevels now derives a durable R floor from two EXISTS
subqueries against EntityModeration (Succeeded + nsfw/blocked labels)
and ArticleReport/Report (Actioned NSFW reports). Level becomes
GREATEST(userNsfwLevel, image-derived, moderation-floor). Image
downgrades still work; user-driven downgrades remain blocked upstream
by the existing auto-clamp in upsertArticle.

Callers updated: text-moderation webhook + backfill now just call
updateArticleNsfwLevels([id]); the NSFW-report path in report.service
captures the article id and recomputes after the tx commits so the
subquery observes the new Actioned report.

Also adds a one-off admin cleanup endpoint at
/api/admin/temp/migrate-article-nsfw-level-cleanup for legacy rows
stuck at nsfwLevel = 60. It zeroes userNsfwLevel auto-clamp artifacts
then re-derives via the service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
manuelurenah
2026-04-17 19:18:51 -04:00
parent ba414f9f14
commit d165ca8ab1
11 changed files with 318 additions and 48 deletions
+2 -2
View File
@@ -37,7 +37,7 @@ The current rollout sequence (§4 of `article-scanning-rollout.md`) is:
2. Run image backfill (`migrate-article-images.ts`).
3. Run text moderation backfill (`migrate-article-text-moderation.ts`).
Between steps 1 and 2, a legacy article's `nsfwLevel` is computed from **just** cover image + `userNsfwLevel` — because `ImageConnection` rows for content images don't exist yet, so `GREATEST(max(cover), max(content))` in `updateArticleNsfwLevels` has nothing to max over on the content side. `article.nsfw` is also still `false` because text moderation hasn't run.
Between steps 1 and 2, a legacy article's `nsfwLevel` is computed from **just** cover image + `userNsfwLevel` — because `ImageConnection` rows for content images don't exist yet, so `GREATEST(max(cover), max(content))` in `updateArticleNsfwLevels` has nothing to max over on the content side. Text moderation hasn't run, so no `floor: NsfwLevel.R` has been applied either. (Pre-2026-04-17: same effective outcome, just expressed as "`article.nsfw` is still `false`".)
So a legacy article with a PG cover, NSFW content images embedded in the body, and NSFW text currently has `nsfwLevel = PG`. After step 1 deploys, Civitai Green filters by `(nsfwLevel & publicBrowsingLevelsFlag) != 0` and happily serves that article. The backfills eventually fix it, but the entire legacy corpus is visible to Green at its **pre-scan** level for the duration of the backfill window.
@@ -270,7 +270,7 @@ A few hundred lines across ~15 files + one DB migration. Doable in a session or
1. **Author-facing UI impact** — the `ArticleScanStatus` component in `ArticleUpsertForm.tsx` currently reads `article.status === Processing` to show scan progress. Does it need to switch to reading `article.ingestion === Pending`? If so, what does the author see while text is still scanning but images are done? (Current behavior on text-only articles: nothing — they publish instantly. New behavior: they see a "text scan pending" indicator?) This is the one place where the 2026-04-10 UX concern could resurface. The solution is probably to keep the author-facing indicator quiet for text-only articles and only surface it when the delay exceeds some threshold, or when scan fails.
2. **`article.nsfw` text-moderation flag** — still needed or subsumed by `ingestion = Blocked`? Keep both. They track different axes: `nsfw` = "text contains NSFW content, elevate the mask", `ingestion = Blocked` = "text violated ToS, hide from everyone except mods". The webhook handler already sets `nsfw = true` for NSFW labels without blocking, and only flips `status = UnpublishedViolation` for `blocked = true`. That stays the same; we just add the ingestion update alongside.
2. **`article.nsfw` text-moderation flag** — **deprecated 2026-04-17.** Text moderation no longer writes the `nsfw` boolean. Instead, the webhook just calls `updateArticleNsfwLevels([id])`; the service reads the `EntityModeration` row (persisted moments earlier by `recordEntityModerationSuccess`) via an `EXISTS` subquery and applies an R floor when the text was flagged. The field stays in the DB schema for backward compatibility but is no longer read or written anywhere. `ingestion = Blocked` still tracks the orthogonal "text violated ToS" axis via `status = UnpublishedViolation`.
3. **Rescan visibility** — when an author edits a Published article and adds new content, should the article stay visible at the old nsfwLevel until the new scan completes, or should it immediately disappear from Green? The safe answer is "disappear" (flip `ingestion = Rescan`, serving gate hides it). The status-quo-preserving answer is "stay visible at old level" (don't flip ingestion until the new scan completes, at which point update in place). Safe is better for a rollout; revisit later if authors complain.
+42 -9
View File
@@ -7,6 +7,8 @@
> ⚠️ **Read §5.9 before rollout.** A review pass on 2026-04-10 surfaced a real leak window in the current deploy sequence (code ships with `articles: ['public']` before either backfill runs, so Civitai Green sees legacy articles at their pre-scan `nsfwLevel`). The mitigation is either a staged flag flip or a followup `Article.ingestion` refactor — both options are specced out in [`article-ingestion-status-proposal.md`](./article-ingestion-status-proposal.md). Do not merge without picking a path.
> 📌 **Deprecation note (2026-04-17)**: `Article.nsfw` is no longer used as a reference anywhere in the app. The field remains in the DB schema but is neither written to nor read for any enforcement decision. Text moderation and Actioned NSFW user reports now enforce an R floor on `nsfwLevel` via `EXISTS` subqueries against `EntityModeration` and `ArticleReport`/`Report` inside `updateArticleNsfwLevels`. Because the floor is computed from persisted records rather than a flag on the Article row, it survives later image rescans (durable) *and* re-derives to the ground-truth level when those records disappear (e.g. a mod un-actions a report). Image-driven downgrades still work: `nsfwLevel = GREATEST(userNsfwLevel, image-derived level, moderation floor)`. References to `article.nsfw = true` below describe the historical behavior at the time this PR shipped.
---
## 1. TL;DR — what this PR does
@@ -15,7 +17,7 @@ Ships the full "articles get scanned like everything else" system and turns it o
1. **Image extraction** — every `<img>` / `<edge-media>` inside article HTML is walked out of the Tiptap AST, persisted as `Image` + `ImageConnection` rows, and queued for the standard ingestion pipeline (WD14 / Hive / Clavata / Hash). Cover images already went through this; content images now do too.
2. **Text moderation via xGuard** — on every article create/update, `(title + stripped content)` is fire-and-forget submitted to the orchestrator xGuard workflow with `labels: ['nsfw']`. A content-hash on `EntityModeration` dedupes unchanged text.
3. **Composite NSFW level**`updateArticleNsfwLevels` now folds cover image + content images + `userNsfwLevel` + `article.nsfw` into a single `nsfwLevel` mask. When text moderation flips `article.nsfw = true`, the level is written as `nsfwBrowsingLevelsFlag` so the standard `(nsfwLevel & browsingLevel) != 0` filter handles serving-path filtering.
3. **Composite NSFW level**`updateArticleNsfwLevels` computes `GREATEST(userNsfwLevel, image-derived level, moderation floor)`, where the moderation floor is an `EXISTS` subquery against `EntityModeration` (text-mod Succeeded + NSFW-or-blocked) and `ArticleReport`/`Report` (Actioned NSFW reports) that returns R when either is present, else 0. Because the floor is re-evaluated on every recompute, a text-flagged article lands at R minimum regardless of image ratings, and the floor drops automatically if a mod invalidates the underlying record. (Previously this was gated on `a.nsfw = TRUE` forcing the full `nsfwBrowsingLevelsFlag` mask — see deprecation note above.)
4. **Serving-path hygiene for Civitai Green** — closed every read path that previously ignored `nsfwLevel` on articles (`getCivitaiNews`, `getCivitaiEvents`, `getArticleById`, `sitemap-articles.xml`, `/api/og`) so articles can be served to the `public` audience safely.
5. **Profanity filter retired from articles** — orchestrator text moderation supersedes the lexical profanity keyword filter. `ArticleMetadata.profanityMatches`/`profanityEvaluation` fields were removed; the legacy `migrate-profanity-nsfw.ts` admin backfill no longer accepts `entity=articles` (models + bounties still use it).
6. **Backfill endpoints**`migrate-article-images.ts` backfills image links, `migrate-article-text-moderation.ts` backfills xGuard submissions. Both are cancellable on client disconnect.
@@ -69,20 +71,51 @@ The fix uses `GREATEST(max(cover), max(content))` — integer max over powers-of
**Implication for deploy**: running `updateArticleNsfwLevels` over legacy articles (which happens implicitly when backfills complete) will **change existing masks** for any article that mixed PG + NSFW content images under the old code. Watch the articles Meilisearch index for re-index churn during the backfill. This is expected.
### 2.4 `article.nsfw` folds into `nsfwLevel` via `CASE WHEN`
### 2.4 Durable moderation floor via subqueries
Same commit. The UPDATE at `nsfwLevels.service.ts:315-325` now does:
> 🔁 Rewritten 2026-04-17 as part of the `Article.nsfw` deprecation. Original CASE-WHEN design preserved below for historical reference.
The UPDATE at `nsfwLevels.service.ts` now computes the level from three ground-truth sources — image ratings, user-set level, and a moderation floor derived on-the-fly from persisted records:
```sql
SET "nsfwLevel" = (
CASE
WHEN a.nsfw = TRUE THEN ${nsfwBrowsingLevelsFlag}
ELSE GREATEST(a."userNsfwLevel", level."nsfwLevel")
END
WITH level AS (
-- image-derived max (same as before)
...
),
moderation_floor AS (
SELECT a.id,
CASE
WHEN EXISTS (
SELECT 1 FROM "EntityModeration" em
WHERE em."entityType" = 'Article' AND em."entityId" = a.id
AND em.status = 'Succeeded'
AND (em.blocked = TRUE OR 'nsfw' = ANY(em."triggeredLabels"))
) OR EXISTS (
SELECT 1 FROM "ArticleReport" ar
JOIN "Report" r ON r.id = ar."reportId"
WHERE ar."articleId" = a.id
AND r.reason = 'NSFW' AND r.status = 'Actioned'
) THEN 4 -- NsfwLevel.R
ELSE 0
END AS "floor"
...
)
UPDATE "Article" a
SET "nsfwLevel" = GREATEST(a."userNsfwLevel", level."nsfwLevel", mf."floor")
FROM level JOIN moderation_floor mf ON mf.id = level.id
WHERE level.id = a.id
AND GREATEST(a."userNsfwLevel", level."nsfwLevel", mf."floor") != a."nsfwLevel"
```
So the text-moderation webhook only needs to set `article.nsfw = true` and call `updateArticleNsfwLevels([id])` — the CASE branch handles the rest. This matches how `updateModelNsfwLevels` / `updateBountyNsfwLevels` already worked.
Why this shape:
- **Durable**: text-mod floor survives arbitrary later image rescans, because the `EntityModeration` row is the source of truth and gets re-read every time.
- **Reversible**: if a mod un-actions an NSFW report or manually invalidates a text-mod record, the next recompute re-derives `nsfwLevel` from user + image ground truth — no sticky state to clean up.
- **Image-responsive**: replacing NSFW content with SFW content genuinely downgrades `nsfwLevel` (the old ELSE branch's behavior, preserved). User manual downgrade attempts are still blocked upstream by the `upsertArticle` auto-clamp `data.userNsfwLevel = Math.max(data.userNsfwLevel, article.nsfwLevel)` for non-mods.
- **Text-mod-only-flagged articles with PG images** land at `R` (NsfwLevel.R = 4) only — not the full `R|X|XXX|Blocked` mask the old `CASE WHEN` produced. This is the bug fix that motivated this PR.
- The `Article.nsfw` boolean is no longer written or read.
**Historical behavior (deprecated 2026-04-17):** the update was gated on `a.nsfw = TRUE`, setting `nsfwLevel = nsfwBrowsingLevelsFlag` (R|X|XXX|Blocked = 60). The text-mod webhook wrote `nsfw: true` before calling `updateArticleNsfwLevels([id])`. The NSFW-report path in `report.service.ts` did the same. Both are gone; the subqueries replace them.
### 2.5 Articles feature flag opened to `['public']`
+1 -1
View File
@@ -76,7 +76,7 @@ const tooltipProps: Partial<TooltipProps> = {
withinPortal: true,
};
const lockableProperties = ['nsfw', 'userNsfwLevel'];
const lockableProperties = ['userNsfwLevel'];
export const browsingLevelSelectOptions = browsingLevels.map((level) => ({
label: browsingLevelLabels[level],
@@ -0,0 +1,213 @@
import { Prisma } from '@prisma/client';
import * as z from 'zod';
import { pgDbRead } from '~/server/db/pgDb';
import { dbWrite } from '~/server/db/client';
import { WebhookEndpoint } from '~/server/utils/endpoint-helpers';
import { createLogger } from '~/utils/logging';
import { booleanString } from '~/utils/zod-helpers';
import { updateArticleNsfwLevels } from '~/server/services/nsfwLevels.service';
// One-shot cleanup for the Article.nsfw deprecation (2026-04-17).
//
// Before: `updateArticleNsfwLevels`' CASE WHEN forced `nsfwLevel = 60`
// (R|X|XXX|Blocked = `nsfwBrowsingLevelsFlag`) whenever `a.nsfw = TRUE`.
// Setting the Blocked bit on a non-blocked article was a latent bug —
// blocking is tracked via `ArticleStatus.UnpublishedViolation`, not via
// the nsfwLevel mask.
//
// After the rewrite, `updateArticleNsfwLevels` treats text moderation
// and Actioned NSFW reports as a durable R floor via EXISTS subqueries.
// This migration re-derives legacy `nsfwLevel = 60` rows to the correct
// value by:
//
// 1. Zeroing any `userNsfwLevel = 60` artifact. A composite-mask value
// in `userNsfwLevel` can only come from the upsertArticle auto-clamp
// (Math.max(userNsfwLevel, article.nsfwLevel)), since users pick
// single tiers (1/2/4/8/16) from the form. Leaving it would trap
// the article at 60 forever even after we reset `nsfwLevel`.
// 2. Zeroing `nsfwLevel` on the same rows so the service's GREATEST
// recomputes from image + user + moderation-floor ground truth
// instead of re-inheriting the 60.
// 3. Calling `updateArticleNsfwLevels` — the EntityModeration and
// NSFW-report subqueries pick up the R floor for any article that
// was text-flagged or reported, so text-only-flagged articles with
// PG images land at R exactly.
//
// `article.nsfw = TRUE` is left in place as a historical trace.
type CancelFn = () => Promise<void>;
const log = createLogger('migrate-article-nsfw-level-cleanup', 'blue');
type Stats = {
articlesProcessed: number;
userNsfwLevelsReset: number;
articlesUpdated: number;
};
const querySchema = z.object({
dryRun: booleanString().default(true),
batchSize: z.coerce.number().min(1).max(5000).default(500),
start: z.coerce.number().optional().default(0),
end: z.coerce.number().optional(),
});
type Params = z.infer<typeof querySchema>;
async function fetchMaxArticleId(cancelFns: CancelFn[]) {
const query = await pgDbRead.cancellableQuery<{ max: number | null }>(Prisma.sql`
SELECT MAX(id) "max" FROM "Article" WHERE "nsfwLevel" = 60 AND nsfw = TRUE
`);
cancelFns.push(query.cancel);
const results = await query.result();
return results[0]?.max ?? 0;
}
export default WebhookEndpoint(async (req, res) => {
const parsed = querySchema.safeParse(req.query);
if (!parsed.success) {
return res.status(400).json({ ok: false, error: z.treeifyError(parsed.error) });
}
const params: Params = parsed.data;
const startTime = Date.now();
log(
`Starting nsfwLevel cleanup${params.dryRun ? ' (DRY RUN)' : ''} ` +
`batchSize=${params.batchSize} start=${params.start} end=${params.end ?? 'auto'}`
);
const stats: Stats = {
articlesProcessed: 0,
userNsfwLevelsReset: 0,
articlesUpdated: 0,
};
const cancelFns: CancelFn[] = [];
let stopped = false;
res.on('close', async () => {
stopped = true;
log(`Client disconnected, cancelling ${cancelFns.length} in-flight query(ies)...`);
await Promise.all(
cancelFns.map((cancel) =>
cancel().catch((err) => log(`Cancel failed: ${(err as Error).message}`))
)
);
});
try {
const maxId = params.end ?? (await fetchMaxArticleId(cancelFns));
if (maxId === 0) {
log('No articles match nsfwLevel=60 AND nsfw=TRUE — nothing to do');
res.status(200).json({ ok: true, dryRun: params.dryRun, duration: '0.00s', result: stats });
return;
}
const rangeStart = params.start;
const rangeSize = Math.max(1, maxId - rangeStart + 1);
let cursor = rangeStart;
let batchNumber = 0;
while (!stopped && cursor <= maxId) {
batchNumber++;
const batchStart = Date.now();
const idsQuery = await pgDbRead.cancellableQuery<{ id: number }>(Prisma.sql`
SELECT id FROM "Article"
WHERE "nsfwLevel" = 60
AND nsfw = TRUE
AND id >= ${cursor}
AND id <= ${maxId}
ORDER BY id ASC
LIMIT ${params.batchSize}
`);
cancelFns.push(idsQuery.cancel);
let rows: { id: number }[];
try {
rows = await idsQuery.result();
} catch (error) {
if (stopped) break;
throw error;
}
if (rows.length === 0) {
log(`[batch ${batchNumber}] No more candidates — done`);
break;
}
const ids = rows.map((r) => r.id);
const firstId = ids[0];
const lastId = ids[ids.length - 1];
log(
`[batch ${batchNumber}] ${rows.length} candidates (IDs ${firstId}-${lastId})` +
(params.dryRun ? ' (DRY RUN — no writes)' : '')
);
if (!params.dryRun) {
// Step 1: clear auto-clamp artifacts in userNsfwLevel. Safe because
// users only ever pick single-tier values from the form (1/2/4/8/16),
// so a literal 60 can only be the Math.max auto-clamp output.
const clampReset = await dbWrite.$executeRaw(Prisma.sql`
UPDATE "Article"
SET "userNsfwLevel" = 0
WHERE id IN (${Prisma.join(ids)}) AND "userNsfwLevel" = 60
`);
stats.userNsfwLevelsReset += Number(clampReset);
// Step 2: clear the buggy 60 so the service's GREATEST recomputes
// from image + user + moderation-floor ground truth.
await dbWrite.$executeRaw(Prisma.sql`
UPDATE "Article"
SET "nsfwLevel" = 0
WHERE id IN (${Prisma.join(ids)}) AND "nsfwLevel" = 60 AND nsfw = TRUE
`);
// Step 3: recompute. EntityModeration + Report subqueries inside
// the service apply the R floor for any article that was text-flagged
// or reported. The service also queues the search index.
await updateArticleNsfwLevels(ids);
stats.articlesUpdated += ids.length;
}
stats.articlesProcessed += ids.length;
cursor = lastId + 1;
const batchDuration = Date.now() - batchStart;
const elapsedSec = (Date.now() - startTime) / 1000;
const progressPct = Math.min(
100,
Math.max(0, ((cursor - rangeStart) / rangeSize) * 100)
).toFixed(1);
const rate = stats.articlesProcessed / Math.max(elapsedSec, 0.001);
log(
`[batch ${batchNumber}] done in ${batchDuration}ms | ` +
`totals: processed=${stats.articlesProcessed} updated=${stats.articlesUpdated} ` +
`userLevelResets=${stats.userNsfwLevelsReset} | ` +
`progress=${progressPct}% (id ${cursor}/${maxId}) | rate=${rate.toFixed(1)}/s | ` +
`elapsed=${elapsedSec.toFixed(1)}s`
);
}
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
log(`Cleanup completed in ${duration}s${stopped ? ' (stopped early)' : ''}`);
res.status(200).json({
ok: true,
dryRun: params.dryRun,
stopped,
duration: `${duration}s`,
result: stats,
});
} catch (error) {
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
log(`Cleanup failed after ${duration}s:`, error);
res.status(500).json({
ok: false,
error: (error as Error).message,
stack: (error as Error).stack,
});
}
});
@@ -387,13 +387,12 @@ export default WebhookEndpoint(async (req, res) => {
await recomputeArticleIngestion(article.id);
stats.succeeded++;
// Apply Article-specific moderation logic (mirrors webhook handler)
// Apply Article-specific moderation logic (mirrors webhook handler).
// The EntityModeration record was persisted above by
// recordEntityModerationSuccess, so the moderation_floor subquery
// in updateArticleNsfwLevels picks up the R floor automatically.
const isNsfw = blocked || triggeredLabels.some((l) => l.toLowerCase() === 'nsfw');
if (isNsfw) {
await dbWrite.article.update({
where: { id: article.id },
data: { nsfw: true },
});
await updateArticleNsfwLevels([article.id]);
stats.flaggedNsfw++;
}
@@ -29,13 +29,11 @@ const entityHandlers: Record<string, (result: TextModerationResult) => Promise<v
// Blocked content is treated as NSFW regardless of triggered labels.
const isNsfw = blocked || triggeredLabels.some((label) => label.toLowerCase() === 'nsfw');
// Elevate the nsfw flag only — never downgrade, since image content or the user
// may have already flagged the article as NSFW for reasons unrelated to text.
// recordEntityModerationSuccess has already persisted the moderation
// result above. updateArticleNsfwLevels's moderation_floor subquery reads
// that record directly, so the R floor is applied intrinsically — no
// parameter or prior write needed.
if (isNsfw) {
await dbWrite.article.update({
where: { id: entityId },
data: { nsfw: true },
});
await updateArticleNsfwLevels([entityId]);
}
+1 -10
View File
@@ -62,7 +62,7 @@ export async function unpublishArticleHandler({
// Fetch current metadata
const article = await dbRead.article.findUnique({
where: { id },
select: { metadata: true, nsfw: true },
select: { metadata: true },
});
if (!article) throw throwNotFoundError(`No article with id ${input.id}`);
@@ -77,15 +77,6 @@ export async function unpublishArticleHandler({
isModerator: ctx.user.isModerator,
});
// Optional: Track analytics event (if article tracking exists)
// if (ctx.track.articleEvent) {
// await ctx.track.articleEvent({
// type: 'Unpublish',
// articleId: id,
// nsfw: article.nsfw,
// });
// }
return {
...updatedArticle,
metadata: updatedArticle.metadata as ArticleMetadata | null,
+1 -1
View File
@@ -168,7 +168,7 @@ export async function getReportsHandler({ input }: { input: GetReportsInput }) {
article: {
select: {
id: true,
nsfw: true,
nsfwLevel: true,
title: true,
publishedAt: true,
tosViolation: true,
-1
View File
@@ -774,7 +774,6 @@ export const upsertArticle = async ({
}: UpsertArticleInput & {
userId: number;
isModerator?: boolean;
nsfw?: boolean;
metadata?: ArticleMetadata;
scanContent?: boolean;
}) => {
+33 -7
View File
@@ -314,17 +314,43 @@ export async function updateArticleNsfwLevels(articleIds: number[]) {
WHERE a.id IN (${Prisma.join(articleIds)})
GROUP BY a.id
),
-- Durable moderation floor: R whenever a Successful text-moderation
-- record flagged the article as NSFW (via triggeredLabels or blocked),
-- OR an Actioned NSFW user report exists. Computed fresh on every
-- recompute so the floor survives image rescans / userNsfwLevel edits
-- without needing a persisted flag on the Article row. When those
-- records disappear (e.g. a mod Unactions the report), the floor drops
-- on the next recompute and the article's level re-derives from
-- user + image ground truth.
moderation_floor AS (
SELECT
a.id,
CASE
WHEN EXISTS (
SELECT 1 FROM "EntityModeration" em
WHERE em."entityType" = 'Article'
AND em."entityId" = a.id
AND em.status = 'Succeeded'::"EntityModerationStatus"
AND (em.blocked = TRUE OR 'nsfw' = ANY(em."triggeredLabels"))
) OR EXISTS (
SELECT 1 FROM "ArticleReport" ar
JOIN "Report" r ON r.id = ar."reportId"
WHERE ar."articleId" = a.id
AND r.reason = 'NSFW'::"ReportReason"
AND r.status = 'Actioned'::"ReportStatus"
) THEN 4 -- NsfwLevel.R
ELSE 0
END AS "floor"
FROM "Article" a
WHERE a.id IN (${Prisma.join(articleIds)})
)
UPDATE "Article" a
SET "nsfwLevel" = (
CASE
WHEN a.nsfw = TRUE THEN ${nsfwBrowsingLevelsFlag}
ELSE GREATEST(a."userNsfwLevel", level."nsfwLevel")
END
)
SET "nsfwLevel" = GREATEST(a."userNsfwLevel", level."nsfwLevel", mf."floor")
FROM level
JOIN moderation_floor mf ON mf.id = level.id
WHERE level.id = a.id
AND (level."nsfwLevel" != a."nsfwLevel" OR a.nsfw = TRUE)
AND GREATEST(a."userNsfwLevel", level."nsfwLevel", mf."floor") != a."nsfwLevel"
RETURNING a.id;
`);
await articlesSearchIndex.queueUpdate(
+17 -6
View File
@@ -20,7 +20,6 @@ import type {
} from '~/server/schema/report.schema';
import { ReportEntity } from '~/shared/utils/report-helpers';
import {
articlesSearchIndex,
collectionsSearchIndex,
imagesMetricsSearchIndex,
imagesSearchIndex,
@@ -32,6 +31,7 @@ import {
refundTransaction,
} from '~/server/services/buzz.service';
import { queueImageSearchIndexUpdate, updateNsfwLevel } from '~/server/services/image.service';
import { updateArticleNsfwLevels } from '~/server/services/nsfwLevels.service';
import { trackModActivity } from '~/server/services/moderator.service';
import { createNotification } from '~/server/services/notification.service';
import { addTagVotes } from '~/server/services/tag.service';
@@ -168,7 +168,9 @@ export const createReport = async ({
: null;
if (validReport) return validReport;
return await dbWrite.$transaction(async (tx) => {
let recomputeArticleNsfwLevelId: number | null = null;
const createdReport = await dbWrite.$transaction(async (tx) => {
// create the report
const createdReport = await tx.report.create({
data: {
@@ -242,10 +244,11 @@ export const createReport = async ({
]);
break;
case ReportEntity.Article:
await tx.article.update({ where: { id }, data: { nsfw: true } });
await articlesSearchIndex.queueUpdate([
{ id, action: SearchIndexUpdateQueueAction.Update },
]);
// Defer the nsfwLevel recompute until after the tx commits so the
// moderation_floor subquery in updateArticleNsfwLevels can observe
// the Actioned NSFW report we just created. The recompute queues
// its own search-index update if the level actually changed.
recomputeArticleNsfwLevelId = id;
break;
case ReportEntity.Post:
await tx.post.update({ where: { id }, data: { nsfw: true } });
@@ -291,6 +294,14 @@ export const createReport = async ({
return createdReport;
});
// Runs after the tx commits so the subquery in updateArticleNsfwLevels
// picks up the newly-Actioned NSFW report we just inserted.
if (recomputeArticleNsfwLevelId !== null) {
await updateArticleNsfwLevels([recomputeArticleNsfwLevelId]);
}
return createdReport;
};
// TODO - add reports for questions/answers