Skip to content

Add sensitive media backend support - #1958

Draft
vitrixbot wants to merge 22 commits into
TryGhost:mainfrom
vitrixbot:codex/sensitive-media-support
Draft

Add sensitive media backend support#1958
vitrixbot wants to merge 22 commits into
TryGhost:mainfrom
vitrixbot:codex/sensitive-media-support

Conversation

@vitrixbot

@vitrixbot vitrixbot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Context

This is the ActivityPub backend side of the sensitive media and content warning work. It supports the Ghost admin UI changes that display sensitive-media warnings, content-warning disclosures, and the user preference for showing sensitive media by default.

TryGhost/Ghost#29054

CleanShot 2026-07-02 at 19 38 36@2x CleanShot 2026-07-02 at 18 09 34@2x

The key distinction from the session is:

  • sensitive is the persisted ActivityPub media/post flag used by the UI to decide whether media needs a sensitive warning.
  • contentWarning is derived from remote sensitive posts with a summary and is treated as post-level disclosure by the UI.
  • Internal Ghost post summaries remain summaries/excerpts and are not treated as content warnings.
  • showSensitiveMedia is a site/user preference used by the admin UI to decide whether sensitive media should be shown without the warning overlay.

What changed

  • Added a migration for:
    • posts.sensitive
    • users.show_sensitive_media
  • Persists incoming ActivityPub sensitive values for Notes and Articles.
  • Updates existing remote posts when only the sensitive flag changes.
  • Exposes sensitive and derived contentWarning fields in post DTOs.
  • Adds the fields to feed, profile/account posts, reply chain, and notification API responses.
  • Derives contentWarning only for remote sensitive posts with a non-empty summary.
  • Adds a GET/PUT /.ghost/activitypub/v1/preferences API for the showSensitiveMedia setting.
  • Registers the new preferences controller in the app/container.
  • Adds tests and snapshots for persistence, DTO mapping, feed responses, profile posts, reply chains, notifications, and preferences.
  • Includes progress.md documenting the implementation and audit notes from this work.

Why

The Ghost admin needs enough backend information to render sensitive media and content warnings differently:

  • media-level sensitive flags need a blur/reveal treatment;
  • content warnings need a post-level reveal treatment;
  • a global user preference needs to bypass sensitive-media warnings without losing the underlying metadata;
  • internal Ghost summaries should not accidentally become content warnings.

Validation

  • git diff --check origin/main...HEAD
  • Reviewed backend diff against origin/main for the changed persistence, API, and DTO paths.
  • Attempted a targeted pnpm test -- --run ... pass, but pnpm stopped before Vitest because dependency install hit ignored build scripts in the local checkout. No tracked source changes were left behind from that attempt.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds sensitive media support end-to-end: new database columns (posts.sensitive, users.show_sensitive_media), post entity/repository/service updates, a getContentWarning helper used in feed, notification, account-posts, and reply-chain DTO mapping, expanded API types, a new PreferencesController with GET/PUT endpoints, DI/routing registration, and updated tests/snapshots plus a progress log.

Estimated code review effort: 4 (Complex) | ~60 minutes

Changes

Area Change
Database New migration adding posts.sensitive and users.show_sensitive_media columns
Post entity/repository/service Added sensitive field propagation through creation, update, deletion, and persistence
Feed/Notification/Account/ReplyChain views Added post_sensitive/author_is_internal query fields and contentWarning DTO mapping
Helper New getContentWarning function
API types Extended PostDTO/NotificationDTO with sensitive/contentWarning
Preferences API New PreferencesController (GET/PUT /preferences), DI/routing registration
Tests/Snapshots Updated unit/integration tests and JSON snapshots across affected areas
Docs Added progress.md log

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PreferencesController
  participant UsersTable

  Client->>PreferencesController: GET /preferences
  PreferencesController->>UsersTable: SELECT show_sensitive_media WHERE site_id
  UsersTable-->>PreferencesController: row
  PreferencesController-->>Client: { showSensitiveMedia }

  Client->>PreferencesController: PUT /preferences { showSensitiveMedia }
  PreferencesController->>PreferencesController: validate payload via UpdatePreferencesSchema
  alt invalid JSON or schema
    PreferencesController-->>Client: 400
  else valid
    PreferencesController->>UsersTable: UPDATE show_sensitive_media WHERE site_id
    UsersTable-->>PreferencesController: ack
    PreferencesController-->>Client: { showSensitiveMedia }
  end
Loading

Related issues: None specified.

Related PRs: None specified.

Suggested labels: feature, backend, database, api

Suggested reviewers: None specified.

Poem

A rabbit hopped through columns new,
sensitive flags in every view,
Preferences GET and PUT take flight,
Content warnings shown just right,
Snapshots updated, tests all pass —
This burrow's built to last, at last! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding backend support for sensitive media.
Description check ✅ Passed The description is directly related and accurately summarizes the backend sensitive-media and content-warning changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

vitrixbot added 8 commits July 2, 2026 19:36
Added the backend storage and ingestion groundwork for sensitive media so later API and client slices can rely on a persisted post-level flag.
Exposes persisted sensitive flags and remote content-warning labels through additive ActivityPub API response fields so clients can hide or reveal sensitive media without conflating Ghost custom excerpts with content warnings.
Exposes the persisted sensitive media display preference through the ActivityPub API so clients can read and update the current site's default reveal behavior.
no ref

The cross-repo implementation log needs to record the frontend display slice checks, browser verification, and review deviations so the final review can compare actual work against the original plan.
no ref

The settings toggle slice added the final user-facing preference control, so the implementation log now records the red/green tests, review follow-up, and authenticated Admin verification.
Record the UI relocation, verification, and review follow-up so the implementation log reflects the current product direction.
no ref

Record the missed-surface fixes, red/green verification, browser limitation, and adversarial review follow-up for the sensitive media implementation log.
no ref

Record the final requirement audit, quality gates, and browser verification for the sensitive media implementation.
@JohnONolan

Copy link
Copy Markdown
Member

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/http/api/helpers/post.unit.test.ts (1)

142-145: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use field-level assertions for the DTO fields under test.

These tests currently assert grouped object fragments; switching to direct field assertions keeps failures focused on the sensitive/content-warning contract.

As per coding guidelines, “Assertions should target the specific field being tested, not stringify the whole object; prefer expect(result.field).toEqual(...) over expect(JSON.stringify(result)).toContain(...).”

Proposed assertion refactor
-        expect(dto).toMatchObject({
-            sensitive: true,
-            contentWarning: null,
-        });
+        expect(dto.sensitive).toBe(true);
+        expect(dto.contentWarning).toBeNull();
@@
-        expect(dto).toMatchObject({
-            sensitive: true,
-            summary: 'Sensitive topic',
-            contentWarning: 'Sensitive topic',
-        });
+        expect(dto.sensitive).toBe(true);
+        expect(dto.summary).toBe('Sensitive topic');
+        expect(dto.contentWarning).toBe('Sensitive topic');
@@
-        expect(dto).toMatchObject({
-            sensitive: true,
-            summary: 'Sensitive topic',
-            contentWarning: 'Sensitive topic',
-        });
+        expect(dto.sensitive).toBe(true);
+        expect(dto.summary).toBe('Sensitive topic');
+        expect(dto.contentWarning).toBe('Sensitive topic');
@@
-        expect(dto).toMatchObject({
-            sensitive: true,
-            summary: 'Custom excerpt',
-            contentWarning: null,
-        });
+        expect(dto.sensitive).toBe(true);
+        expect(dto.summary).toBe('Custom excerpt');
+        expect(dto.contentWarning).toBeNull();

Also applies to: 161-165, 181-185, 200-204

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http/api/helpers/post.unit.test.ts` around lines 142 - 145, The DTO tests
are using grouped object assertions for the sensitive/content-warning contract
instead of field-level checks. Update the relevant cases in post.unit.test.ts to
assert each DTO property directly on dto using specific field expectations, and
apply the same refactor in the other listed blocks so failures stay focused on
the exact field under test.

Source: Coding guidelines

src/http/api/preferences.controller.ts (1)

8-12: 📐 Maintainability & Code Quality | 🔵 Trivial

Consider z.strictObject() per Zod v4 idiom.

z.object({...}).strict() still works, but Zod v4 introduces z.strictObject() as the dedicated API for this pattern.

♻️ Optional refactor
-const UpdatePreferencesSchema = z
-    .object({
-        showSensitiveMedia: z.boolean(),
-    })
-    .strict();
+const UpdatePreferencesSchema = z.strictObject({
+    showSensitiveMedia: z.boolean(),
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http/api/preferences.controller.ts` around lines 8 - 12, The preferences
schema uses z.object(...).strict() in UpdatePreferencesSchema, but this should
follow the Zod v4 idiom. Update the schema definition in the preferences
controller to use z.strictObject(...) instead, keeping the same
showSensitiveMedia field and behavior while preserving the existing schema name
for the controller logic.
src/feed/feed.service.ts (1)

33-64: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the repeated author_is_internal join/CASE fragment.

The LEFT JOIN users as author_user ... CASE WHEN author_user.id IS NOT NULL THEN 1 ELSE 0 END AS author_is_internal block is duplicated verbatim in getFeedData and getDiscoveryFeedData here, and again (twice) in src/http/api/views/account.posts.view.ts. Extracting a small shared helper (e.g. withAuthorIsInternal(query, authorAccountAlias)) would reduce copy/paste risk if the internal-author detection logic ever changes.

Also applies to: 171-176, 205-209, 328-333, 348-352

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/feed/feed.service.ts` around lines 33 - 64, The repeated
author_is_internal join/CASE logic is duplicated in feed queries, so centralize
it into a shared helper and reuse it everywhere. Extract the LEFT JOIN users as
author_user plus the CASE WHEN author_user.id IS NOT NULL THEN 1 ELSE 0 END AS
author_is_internal fragment into a helper such as withAuthorIsInternal(query,
authorAccountAlias), then call that from getFeedData and getDiscoveryFeedData
(and the matching account.posts.view query builders) to keep the internal-author
detection logic consistent.
src/http/api/notification.controller.unit.test.ts (1)

14-92: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing test coverage for internal-author (Ghost) sensitive posts.

The only scenario tested has post_author_is_internal: 0/in_reply_to_post_author_is_internal: 0. The PR's key behavioral rule — that contentWarning must stay null for internal (Ghost) posts even when sensitive is true and a summary exists — isn't exercised here.

✅ Suggested additional test case
     it('maps sensitive and content warnings for notification post DTOs', async () => {
         ...
     });
+
+    it('does not derive a content warning for internal (Ghost) sensitive posts', async () => {
+        (notificationService.getNotificationsData as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
+            results: [
+                {
+                    // ...same fixture shape...
+                    post_sensitive: 1,
+                    post_summary: 'Internal excerpt',
+                    post_author_is_internal: 1,
+                    in_reply_to_post_sensitive: 1,
+                    in_reply_to_post_summary: 'Internal reply excerpt',
+                    in_reply_to_post_author_is_internal: 1,
+                },
+            ],
+            nextCursor: null,
+        });
+
+        const response = await notificationController.handleGetNotifications(ctx);
+        const body = await response.json();
+
+        expect(body.notifications[0].post).toMatchObject({
+            sensitive: true,
+            contentWarning: null,
+        });
+        expect(body.notifications[0].inReplyTo).toMatchObject({
+            sensitive: true,
+            contentWarning: null,
+        });
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http/api/notification.controller.unit.test.ts` around lines 14 - 92, Add
a new unit test in notification.controller.unit.test.ts alongside
handleGetNotifications that covers internal/Ghost authors by setting
NotificationService.getNotificationsData to return posts with
post_author_is_internal and in_reply_to_post_author_is_internal set to true/1
while sensitive is true and summaries are present. Assert through
NotificationController.handleGetNotifications that the mapped post and inReplyTo
DTOs keep sensitive true but leave contentWarning null, so the behavior is
verified for both main post and reply fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/http/api/preferences.controller.ts`:
- Around line 14-64: PreferencesController is accessing the database directly
instead of going through the service/repository layer, and it also skips the
Result pattern used elsewhere. Move the select/update logic out of
handleGetPreferences and handleUpdatePreferences into a PreferencesService
backed by a repository, and have the controller call that service instead of
using Knex directly. Make the service return Result values and update the
controller to handle those with isError(), getError(), and getValue() before
returning the HTTP response.

---

Nitpick comments:
In `@src/feed/feed.service.ts`:
- Around line 33-64: The repeated author_is_internal join/CASE logic is
duplicated in feed queries, so centralize it into a shared helper and reuse it
everywhere. Extract the LEFT JOIN users as author_user plus the CASE WHEN
author_user.id IS NOT NULL THEN 1 ELSE 0 END AS author_is_internal fragment into
a helper such as withAuthorIsInternal(query, authorAccountAlias), then call that
from getFeedData and getDiscoveryFeedData (and the matching account.posts.view
query builders) to keep the internal-author detection logic consistent.

In `@src/http/api/helpers/post.unit.test.ts`:
- Around line 142-145: The DTO tests are using grouped object assertions for the
sensitive/content-warning contract instead of field-level checks. Update the
relevant cases in post.unit.test.ts to assert each DTO property directly on dto
using specific field expectations, and apply the same refactor in the other
listed blocks so failures stay focused on the exact field under test.

In `@src/http/api/notification.controller.unit.test.ts`:
- Around line 14-92: Add a new unit test in notification.controller.unit.test.ts
alongside handleGetNotifications that covers internal/Ghost authors by setting
NotificationService.getNotificationsData to return posts with
post_author_is_internal and in_reply_to_post_author_is_internal set to true/1
while sensitive is true and summaries are present. Assert through
NotificationController.handleGetNotifications that the mapped post and inReplyTo
DTOs keep sensitive true but leave contentWarning null, so the behavior is
verified for both main post and reply fields.

In `@src/http/api/preferences.controller.ts`:
- Around line 8-12: The preferences schema uses z.object(...).strict() in
UpdatePreferencesSchema, but this should follow the Zod v4 idiom. Update the
schema definition in the preferences controller to use z.strictObject(...)
instead, keeping the same showSensitiveMedia field and behavior while preserving
the existing schema name for the controller logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9843f4e4-9ece-402f-a978-c4037bd33b5b

📥 Commits

Reviewing files that changed from the base of the PR and between 6cffd85 and 1e62f22.

📒 Files selected for processing (31)
  • migrate/migrations/000083_add-sensitive-media-support.down.sql
  • migrate/migrations/000083_add-sensitive-media-support.up.sql
  • progress.md
  • src/app.ts
  • src/configuration/registrations.ts
  • src/feed/feed.service.ts
  • src/http/api/__snapshots__/feed.json
  • src/http/api/__snapshots__/post-authored-by-me.json
  • src/http/api/__snapshots__/post-liked-by-me.json
  • src/http/api/__snapshots__/post-reposted-by-me.json
  • src/http/api/__snapshots__/post.json
  • src/http/api/feed.controller.ts
  • src/http/api/feed.unit.test.ts
  • src/http/api/helpers/post.ts
  • src/http/api/helpers/post.unit.test.ts
  • src/http/api/notification.controller.ts
  • src/http/api/notification.controller.unit.test.ts
  • src/http/api/preferences.controller.integration.test.ts
  • src/http/api/preferences.controller.ts
  • src/http/api/types.ts
  • src/http/api/views/account.posts.view.ts
  • src/http/api/views/account.posts.view.unit.test.ts
  • src/http/api/views/reply.chain.view.integration.test.ts
  • src/http/api/views/reply.chain.view.ts
  • src/notification/__snapshots__/get-notifications-data.json
  • src/notification/notification.service.ts
  • src/post/post.entity.ts
  • src/post/post.repository.knex.integration.test.ts
  • src/post/post.repository.knex.ts
  • src/post/post.service.integration.test.ts
  • src/post/post.service.ts

Comment thread src/http/api/preferences.controller.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/preferences/preferences.repository.knex.ts`:
- Around line 21-30: The updateForSite method in PreferencesRepositoryKnex
ignores the affected-row count from this.db('users').where({ site_id: siteId
}).update(...), so a missing row can look successful. Capture the update result
in updateForSite, verify at least one row was modified, and handle the zero-row
case explicitly (for example by throwing or returning an error/empty result)
before returning PreferencesDTO.
- Around line 10-19: The getForSite method in PreferencesRepository currently
treats a missing users row as showSensitiveMedia=false because
Boolean(user?.show_sensitive_media) silently falls back, which hides invalid
site_id cases. Update getForSite to explicitly handle the .first() result from
the users query: if no row is found, surface that as an error/exception (or
otherwise explicitly signal missing data) instead of returning a default
preference. Keep the normal mapping from show_sensitive_media to PreferencesDTO
only for a real user row.

In `@src/preferences/preferences.service.ts`:
- Line 9: `PreferencesServiceError` is currently typed as `never`, but
`PreferencesService` can fail when repository calls return missing-site/user or
DB errors. Update `PreferencesServiceError` in `PreferencesService` to be a real
error object type with context, and make the service methods that use
`getForSite`/`updateForSite` propagate repository failures through the Result
channel instead of always wrapping everything in `ok(...)`. Ensure the typed
error is used consistently so callers can distinguish successful reads/updates
from not-found or database failure cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9560504c-3dad-476c-b859-6959549de23f

📥 Commits

Reviewing files that changed from the base of the PR and between 1e62f22 and 5b5a821.

📒 Files selected for processing (7)
  • src/configuration/registrations.ts
  • src/http/api/helpers/post.unit.test.ts
  • src/http/api/notification.controller.unit.test.ts
  • src/http/api/preferences.controller.integration.test.ts
  • src/http/api/preferences.controller.ts
  • src/preferences/preferences.repository.knex.ts
  • src/preferences/preferences.service.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/http/api/notification.controller.unit.test.ts
  • src/http/api/preferences.controller.integration.test.ts
  • src/http/api/helpers/post.unit.test.ts

Comment thread src/preferences/preferences.repository.knex.ts Outdated
Comment thread src/preferences/preferences.repository.knex.ts Outdated
Comment thread src/preferences/preferences.service.ts Outdated
vitrixbot added 2 commits July 2, 2026 21:37
ref TryGhost#1958

Preferences reads and updates should surface stale or invalid site IDs instead of treating them as a successful disabled sensitive-media preference. This also keeps the service aligned with the Result error contract and fixes the Biome import ordering reported by CI.
ref TryGhost#1958

Forked pull requests do not receive the OIDC token required to authenticate with GCP, so the image push and PR deployment jobs need to run only for same-repository PRs or non-PR events. This keeps fork PR CI focused on build and test checks instead of failing on unavailable credentials.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/http/api/preferences.controller.integration.test.ts (1)

69-76: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

New 500 tests won't catch the no-op-update false-positive.

These tests correctly assert a 500 for a genuinely nonexistent site, but they don't cover the case where handleUpdatePreferences is called twice with the same value for an existing user — which, per the affectedRows semantics issue in preferences.repository.knex.ts, would spuriously also return 500. Consider adding a test that updates a preference to its current value and asserts it still succeeds (200), once the repository fix above lands.

Also applies to: 118-128

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http/api/preferences.controller.integration.test.ts` around lines 69 -
76, Add an integration test around
`preferencesController.handleUpdatePreferences` that exercises an existing user
updating a preference to the same value twice and asserts the second call still
returns 200. This covers the `affectedRows` false-positive path in
`preferences.repository.knex.ts` and prevents no-op updates from being treated
like missing-user failures. Keep the existing 500 test for genuinely nonexistent
sites, but extend the `preferences.controller.integration.test.ts` coverage near
`handleGetPreferences`/`handleUpdatePreferences` to distinguish the two cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/http/api/preferences.controller.integration.test.ts`:
- Around line 69-76: Add an integration test around
`preferencesController.handleUpdatePreferences` that exercises an existing user
updating a preference to the same value twice and asserts the second call still
returns 200. This covers the `affectedRows` false-positive path in
`preferences.repository.knex.ts` and prevents no-op updates from being treated
like missing-user failures. Keep the existing 500 test for genuinely nonexistent
sites, but extend the `preferences.controller.integration.test.ts` coverage near
`handleGetPreferences`/`handleUpdatePreferences` to distinguish the two cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c5e32b4f-12fa-404e-9a52-02f4a13cfe9c

📥 Commits

Reviewing files that changed from the base of the PR and between 5b5a821 and e659f48.

📒 Files selected for processing (4)
  • src/configuration/registrations.ts
  • src/http/api/preferences.controller.integration.test.ts
  • src/preferences/preferences.repository.knex.ts
  • src/preferences/preferences.service.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/configuration/registrations.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/preferences/preferences.repository.knex.ts (1)

7-30: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Repository throws exceptions instead of using the codebase's Result type convention.

Both getForSite and updateForSite throw PreferencesUserNotFoundError directly. The repo's documented architecture requires error objects with context to be returned through a Result type (consumed via isError()/getValue()/getError()), not thrown as exceptions, so that callers get a typed, exhaustive error-handling path instead of relying on try/catch.

While PreferencesUserNotFoundError correctly carries context (siteId), it bypasses the Result type wrapper this codebase standardizes on for fallible repository operations.

As per coding guidelines, "Always use the Result type helper functions (isError(), getError(), getValue()) rather than destructuring directly" and "Use error objects with context in Result types instead of string literal errors."

Also applies to: 32-48

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/preferences/preferences.repository.knex.ts` around lines 7 - 30,
`KnexPreferencesRepository.getForSite` (and the matching `updateForSite` path)
is throwing `PreferencesUserNotFoundError` instead of following the repository
`Result` convention. Change these methods to return a `Result` that wraps either
the preferences value or a contextual error object, and make callers consume it
with `isError()`, `getValue()`, and `getError()` rather than try/catch. Keep
`PreferencesUserNotFoundError` as the context-carrying error type, but return it
through the `Result` helper instead of throwing it directly.

Source: Coding guidelines

🧹 Nitpick comments (1)
src/preferences/preferences.repository.knex.ts (1)

32-48: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

TOCTOU race between existence check and update.

updateForSite selects to verify the user exists, then issues a separate update() without checking its result. If the row is deleted between the two queries, the method still returns preferences as if the write succeeded, even though nothing was persisted. Wrapping both queries in a transaction (optionally with .forUpdate() row locking) closes this gap.

🛡️ Proposed fix using a transaction
     async updateForSite(
         siteId: number,
         preferences: PreferencesDTO,
     ): Promise<PreferencesDTO> {
-        const userExists = await this.db('users')
-            .select('id')
-            .where({ site_id: siteId })
-            .first();
-
-        if (!userExists) {
-            throw new PreferencesUserNotFoundError(siteId);
-        }
-
-        await this.db('users').where({ site_id: siteId }).update({
-            show_sensitive_media: preferences.showSensitiveMedia,
-        });
-
-        return preferences;
+        return this.db.transaction(async (trx) => {
+            const userExists = await trx('users')
+                .select('id')
+                .where({ site_id: siteId })
+                .forUpdate()
+                .first();
+
+            if (!userExists) {
+                throw new PreferencesUserNotFoundError(siteId);
+            }
+
+            await trx('users').where({ site_id: siteId }).update({
+                show_sensitive_media: preferences.showSensitiveMedia,
+            });
+
+            return preferences;
+        });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/preferences/preferences.repository.knex.ts` around lines 32 - 48, The
`updateForSite` method in `PreferencesRepositoryKnex` has a TOCTOU gap because
it checks for a user with a separate query and then performs `update()` without
validating that the write actually affected a row. Fix this by moving the
existence check and update into a single transaction, and consider locking the
matched row with `.forUpdate()` on the initial lookup. After the `users` update,
verify the affected row count and throw `PreferencesUserNotFoundError` if no row
was updated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/preferences/preferences.repository.knex.ts`:
- Around line 7-30: `KnexPreferencesRepository.getForSite` (and the matching
`updateForSite` path) is throwing `PreferencesUserNotFoundError` instead of
following the repository `Result` convention. Change these methods to return a
`Result` that wraps either the preferences value or a contextual error object,
and make callers consume it with `isError()`, `getValue()`, and `getError()`
rather than try/catch. Keep `PreferencesUserNotFoundError` as the
context-carrying error type, but return it through the `Result` helper instead
of throwing it directly.

---

Nitpick comments:
In `@src/preferences/preferences.repository.knex.ts`:
- Around line 32-48: The `updateForSite` method in `PreferencesRepositoryKnex`
has a TOCTOU gap because it checks for a user with a separate query and then
performs `update()` without validating that the write actually affected a row.
Fix this by moving the existence check and update into a single transaction, and
consider locking the matched row with `.forUpdate()` on the initial lookup.
After the `users` update, verify the affected row count and throw
`PreferencesUserNotFoundError` if no row was updated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3fe43d72-3b67-4fb2-93a2-5060f7dd7de0

📥 Commits

Reviewing files that changed from the base of the PR and between e659f48 and eb9caba.

📒 Files selected for processing (3)
  • .github/workflows/cicd.yml
  • src/http/api/preferences.controller.integration.test.ts
  • src/preferences/preferences.repository.knex.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/http/api/preferences.controller.integration.test.ts

@TryGhost TryGhost deleted a comment from cursor Bot Jul 6, 2026
@TryGhost TryGhost deleted a comment from cursor Bot Jul 6, 2026
@TryGhost TryGhost deleted a comment from cursor Bot Jul 6, 2026
@sagzy
sagzy marked this pull request as draft August 12, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants