Skip to content

feat: integrate OpenAI Moderation API for listings and messages#40

Merged
jaydonkc merged 3 commits intodevfrom
feature/POLY-31-openai-moderation
Feb 18, 2026
Merged

feat: integrate OpenAI Moderation API for listings and messages#40
jaydonkc merged 3 commits intodevfrom
feature/POLY-31-openai-moderation

Conversation

@cole-hackman
Copy link
Copy Markdown
Collaborator

@cole-hackman cole-hackman commented Feb 13, 2026

Linked Issues

Linear: POLY-31, POLY-36, POLY-37

Summary

Integrate the OpenAI Moderation API to screen user-generated content (listings and messages) before persistence, with graceful degradation and audit logging.

Backend changes:

  • New file: moderation.ts
    • moderateContent internalAction — calls OpenAI Moderation API, returns { flagged, categories }
    • logModerationResult internalMutation — persists results to moderationResults table for audit
    • Graceful degradation: if OPENAI_API_KEY is unset or API fails, content is allowed through with a console warning
  • Schema: schema.ts
    • Added moderationResults table with contentType, contentId, inputText, flagged, categories, userId, createdAt
    • Indexes: by_userId, by_contentType
  • Listings: listings.ts
    • Refactored createListing from mutation → action with internalCreateListing mutation
    • Refactored updateListing from mutation → action with internalUpdateListing mutation
    • Added internalGetListing query for ownership verification within actions
    • Both screen title + description via moderateContent before persistence
    • Throws ConvexError("Your listing contains content that violates our guidelines...") on flagged content
  • Messages: messages.ts
    • Refactored sendMessage from mutation → action with internalSendMessage mutation
    • Screens message body via moderateContent before persistence
    • Throws ConvexError("Your message contains content that violates our community guidelines...") on flagged content
  • Config: jest.config.js
    • Added diagnostics: false to ts-jest — required because convex-test serializes action return values to plain strings, losing branded Id types

Test changes:

  • listings.test.ts.mutation().action() for createListing/updateListing, added fetch mock + moderation module
  • messages.test.ts.mutation().action() for sendMessage, added fetch mock + moderation module
  • listings-pagination.test.ts.mutation().action() for createListing, added fetch mock + moderation module
  • reports.test.ts.mutation().action() for createListing, added fetch mock + moderation module
  • testUtils.ts — Added moderation module to shared test modules

Environment variables:

  • OPENAI_API_KEY — New, documented in .env.example with placeholder value. Moderation skips if unset. I will have the key added to our shared .env file

How to Test

Steps to verify locally:

npm run lint
npx jest --no-coverage

Manual flow:

  1. npm run dev:backend (in terminal A)
  2. npm run dev (in terminal B)

Verify the change:

  • Create a listing with normal content → succeeds as before
  • Create a listing with flagged content (e.g. violent/hateful text) → rejected with user-friendly error
  • Send a message with normal content → succeeds as before
  • Send a message with flagged content → rejected with user-friendly error
  • Update a listing with flagged content → rejected with user-friendly error
  • Check moderationResults table in Convex dashboard → results are logged for each moderation check

⚠️ Frontend note: Frontend components will need to switch from useMutation to useAction for createListing, updateListing, and sendMessage in a follow-up PR.

Checklist

  • Tests added/updated (all 4 test files updated for action-based API)
  • Lint/tests pass locally (117 tests, 8 suites)
  • Docs updated (.env.example updated with OPENAI_API_KEY)
  • Follows conventional commit format
  • No merge conflicts with dev
  • No secrets committed (.env.example uses placeholder values only)

Summary by CodeRabbit

  • New Features

    • Content moderation added for listings and messages; flagged content is blocked.
    • Moderation outcomes are recorded for auditing.
  • Security / Behavior

    • Create/update/listing and send-message flows enforce ownership and participant checks.
    • API interactions now use action-based invocations, with improved error reporting surfaced to the UI.
  • Chores

    • Example environment variable added for configuring the OpenAI API key.

- Add moderationResults table to schema
- Create moderation.ts with moderateContent internalAction
- Refactor createListing/updateListing from mutations to actions
- Refactor sendMessage from mutation to action
- Add ConvexError responses for flagged content
- Update all test files for action-based API with fetch mocks
- Disable ts-jest diagnostics for convex-test ID serialization
- Document OPENAI_API_KEY in .env.example

Resolves: POLY-31, POLY-36, POLY-37
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

Adds an OpenAI moderation service and integrates it into listings and messaging flows by converting key mutations into actions that run moderation and persist via new internal queries/mutations; tests, frontend hooks, schema, and env updated to support moderation and action-based APIs.

Changes

Cohort / File(s) Summary
Environment Configuration
backend/.env.example
Added OPENAI_API_KEY entry.
Moderation System & Schema
backend/convex/moderation.ts, backend/convex/schema.ts
New moderation module: moderateContent (internalAction) and logModerationResult (internalMutation); new moderationResults table and indexes.
Listings Backend
backend/convex/listings.ts
Converted create/update to actions; added internalGetListing, internalGetProfile, internalCreateListing, internalUpdateListing; added moderation checks and internal persistence gates.
Messages Backend
backend/convex/messages.ts
Converted sendMessage to an action; added internalGetConversation, internalSendMessage; added auth checks, moderation step, and internal persistence.
Tests
backend/convex/__tests__/*
Updated tests to use action(...) instead of mutation(...), wired moderation module into test harness, and added global fetch mock for moderation responses.
Test Utilities
backend/convex/__tests__/testUtils.ts
Added moderation module to test module resolution map.
Build / Jest
jest.config.js
Disabled TypeScript diagnostics in ts-jest transform (diagnostics: false).
Frontend Hooks
frontend/app/listings/new.tsx, frontend/app/listings/[id]/edit.tsx
Replaced useMutation with useAction for api.listings.createListing / updateListing; improved ConvexError-aware error handling.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant CreateAction as "createListing Action"
    participant Moderation as "moderateContent (OpenAI)"
    participant InternalMutation as "internalCreateListing"
    participant DB as Database

    Client->>CreateAction: call action(title, description, ...)
    CreateAction->>CreateAction: validate inputs, get profile
    CreateAction->>Moderation: internal.moderation.moderateContent(text, contentType, userId)
    Moderation->>Moderation: call OpenAI Moderation API
    alt Not flagged
        Moderation-->>CreateAction: {flagged: false, categories: {}}
        CreateAction->>InternalMutation: internal.listings.internalCreateListing(persist payload)
        InternalMutation->>DB: insert listing, log moderation result
        InternalMutation-->>CreateAction: listingId
        CreateAction-->>Client: return listingId
    else Flagged
        Moderation-->>CreateAction: {flagged: true, categories: {...}}
        CreateAction-->>Client: throw Error (content flagged)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • jaydonkc

Poem

🐰 I hopped in with a careful sniff,

Flags and logs to catch a whiff,
Actions guard each listing's start,
Messages checked before they part,
Moderation logged — a peaceful cart. 🥕

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: integrating OpenAI Moderation API for listings and messages, which is the primary objective of this changeset.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering linked issues, detailed summary of backend/test/config changes, testing instructions, and a complete checklist with all items marked complete.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into dev

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/POLY-31-openai-moderation

No actionable comments were generated in the recent review. 🎉


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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 (9)
jest.config.js (1)

22-22: Disabling TS diagnostics hides type errors during test runs.

This is a reasonable workaround for convex-test serialization issues, but be aware that genuine type errors in test files will no longer be caught by Jest. Consider adding a separate tsc --noEmit step in CI to retain type safety.

backend/convex/schema.ts (1)

123-134: Consider adding an index on contentId for per-item moderation lookups.

If you ever need to retrieve moderation history for a specific listing or message (e.g., to show admins how many times a piece of content was flagged), the current indexes won't cover that query efficiently. A compound index like by_contentType_contentId would help.

Also, inputText stores the full user-submitted text. Consider documenting a retention/purge policy for this table, especially for flagged content that may contain harmful material.

Suggested index addition
   .index('by_userId', ['userId'])
-  .index('by_contentType', ['contentType', 'createdAt']),
+  .index('by_contentType', ['contentType', 'createdAt'])
+  .index('by_contentType_contentId', ['contentType', 'contentId']),
backend/convex/listings.ts (3)

588-599: Moderation runs on every create regardless of text content, which is correct. But consider: the contentId is not passed for new listings.

For audit trail completeness, consider logging the contentId after creation by calling logModerationResult again with the newly created listingId. Currently the moderation log for new listings won't have a contentId to correlate back to the listing.


717-732: Moderation runs on every update even when only non-text fields change.

When a user updates only price, images, condition, or category, the action still calls the moderation API with the existing title + description. This adds unnecessary latency to non-text updates.

Skip moderation when no text fields changed
-    // Screen updated text content via moderation
-    const titleToCheck = (update.title as string) ?? listing.title;
-    const descToCheck = (update.description as string) ?? listing.description;
-
-    const moderationResult = await ctx.runAction(internal.moderation.moderateContent, {
-      text: titleToCheck + ' ' + descToCheck,
-      contentType: 'listing',
-      userId: identity.subject,
-      contentId: args.id,
-    });
-
-    if (moderationResult.flagged) {
-      throw new ConvexError(
-        'Your listing contains content that violates our community guidelines. Please revise and try again.'
-      );
-    }
+    // Screen updated text content via moderation (only if text fields changed)
+    if (update.title !== undefined || update.description !== undefined) {
+      const titleToCheck = (update.title as string) ?? listing.title;
+      const descToCheck = (update.description as string) ?? listing.description;
+
+      const moderationResult = await ctx.runAction(internal.moderation.moderateContent, {
+        text: titleToCheck + ' ' + descToCheck,
+        contentType: 'listing',
+        userId: identity.subject,
+        contentId: args.id,
+      });
+
+      if (moderationResult.flagged) {
+        throw new ConvexError(
+          'Your listing contains content that violates our community guidelines. Please revise and try again.'
+        );
+      }
+    }

672-673: Consider typing the update object instead of using Record<string, unknown> with casts.

Using a typed object would eliminate the need for all the as casts at lines 738-750 and make the code safer.

Example typed approach
-    const update: Record<string, unknown> = {};
+    const update: {
+      title?: string;
+      description?: string;
+      price?: number;
+      images?: string[];
+      condition?: 'new' | 'used' | 'refurbished';
+      category?: 'textbooks' | 'electronics' | 'furniture' | 'tickets' | 'other';
+      tags?: string[];
+    } = {};

Then the cast block at lines 735-752 simplifies to:

     await ctx.runMutation(internal.listings.internalUpdateListing, {
       id: args.id,
-      update: {
-        title: update.title as string | undefined,
-        description: update.description as string | undefined,
-        ...
-      },
+      update,
     });
backend/convex/__tests__/messages.test.ts (1)

40-45: No test for moderation rejection in sendMessage.

All tests mock moderation to pass (flagged: false). Consider adding a test where the fetch mock returns flagged: true to verify that sendMessage correctly rejects flagged content with the expected error message.

Example test for flagged content
+    it('rejects message when content is flagged by moderation', async () => {
+      const t = createConvexTest();
+
+      const buyer = await createTestUser(t, 'buyer@calpoly.edu', 'Buyer');
+      const seller = await createTestUser(t, 'seller@calpoly.edu', 'Seller');
+      const listingId = await createTestListing(t, seller.id);
+      const conversationId = await createTestConversation(t, listingId, buyer.id, seller.id);
+
+      // Mock moderation to flag content
+      (global.fetch as jest.Mock).mockResolvedValueOnce({
+        ok: true,
+        json: async () => ({
+          results: [{ flagged: true, categories: { harassment: true }, category_scores: {} }],
+        }),
+      });
+
+      const asBuyer = t.withIdentity(buyer.identity);
+
+      await expect(async () => {
+        await asBuyer.action(api.messages.sendMessage, {
+          conversationId,
+          body: 'Flagged content',
+        });
+      }).rejects.toThrow('community guidelines');
+    });
backend/convex/__tests__/listings-pagination.test.ts (1)

16-39: Duplicated test setup: modules map and fetch mock are repeated across test files.

This file defines its own modules object and fetch mock instead of reusing createConvexTest() and a shared mock helper from testUtils.ts. The same duplication exists in listings.test.ts. Consider extracting the fetch mock into testUtils.ts alongside the existing createConvexTest() to reduce boilerplate.

backend/convex/__tests__/listings.test.ts (1)

289-312: No test for moderation rejection in createListing or updateListing.

Similar to the messages tests, all listing tests mock moderation as passing. Consider adding at least one test that verifies flagged content is rejected for createListing and updateListing.

backend/convex/moderation.ts (1)

24-27: Graceful-degradation paths skip audit logging — consider logging skipped/failed moderation too.

All four early-return paths (!apiKey, non-OK response, unexpected shape, catch) return { flagged: false, categories: {} } without writing to moderationResults. If you ever need to audit how often moderation was bypassed (outage, missing key, etc.), there will be no record. Consider calling logModerationResult with a distinguishing flag (e.g., flagged: false + a skipped or error status) on at least the error/catch paths.

Also applies to: 45-51, 56-59, 63-67

Comment on lines +86 to +106
export const logModerationResult = internalMutation({
args: {
contentType: v.union(v.literal('listing'), v.literal('message')),
contentId: v.optional(v.string()),
inputText: v.string(),
flagged: v.boolean(),
categories: v.optional(v.string()),
userId: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.insert('moderationResults', {
contentType: args.contentType,
contentId: args.contentId,
inputText: args.inputText,
flagged: args.flagged,
categories: args.categories,
userId: args.userId,
createdAt: Date.now(),
});
},
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Audit table stores full user-submitted text — note PII/compliance implications.

inputText captures the complete message body or listing description. Depending on your compliance posture (GDPR right-to-erasure, CCPA), ensure there is a plan to purge or anonymize old moderationResults rows. This is especially relevant since messages may contain personal information.

… in frontend

Frontend was still using useMutation for createListing and updateListing,
which are now actions after moderation integration. This caused TypeScript
errors: FunctionReference<"action"> not assignable to FunctionReference<"mutation">.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 (2)
frontend/app/listings/new.tsx (1)

122-124: ⚠️ Potential issue | 🟠 Major

Same ConvexError extraction issue as in edit.tsx.

The moderation rejection message is stored in ConvexError.data, not .message. Apply the same fix here:

Proposed fix
+    import { ConvexError } from 'convex/values';
     ...
     } catch (error: unknown) {
-      const errorMessage = error instanceof Error ? error.message : 'Failed to create listing';
+      const errorMessage =
+        error instanceof ConvexError ? (error.data as string) :
+        error instanceof Error ? error.message :
+        'Failed to create listing';
       Alert.alert('Error', errorMessage);
     }
frontend/app/listings/[id]/edit.tsx (1)

119-121: ⚠️ Potential issue | 🟠 Major

Check ConvexError.data instead of error.message for custom error messages.

When the backend throws new ConvexError('Your listing contains content that violates...'), Convex stores the string in the error's .data property. On the client, accessing error.message will not contain the custom message. Since ConvexError extends Error, the current code matches the instanceof Error check and attempts to read .message, which fails to surface the user-facing moderation message.

Proposed fix
+    import { ConvexError } from 'convex/values';
     ...
     } catch (error: unknown) {
-      const errorMessage = error instanceof Error ? error.message : 'Failed to update listing';
+      const errorMessage =
+        error instanceof ConvexError ? (error.data as string) :
+        error instanceof Error ? error.message :
+        'Failed to update listing';
       Alert.alert('Error', errorMessage);
     }

ConvexError stores the custom error string in .data, not .message.
Without this fix, moderation rejection messages would not surface
to the user in the Alert dialog.
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.

2 participants