From 7cec92b3b7d24c45ecab0bdfe84a410077fb0d46 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 20:22:45 +0200 Subject: [PATCH 1/5] refactor: simplify unverify logic in GenericVerifyButton --- src/components/verify/GenericVerifyButton.tsx | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/components/verify/GenericVerifyButton.tsx b/src/components/verify/GenericVerifyButton.tsx index 3844412de..2683fa728 100644 --- a/src/components/verify/GenericVerifyButton.tsx +++ b/src/components/verify/GenericVerifyButton.tsx @@ -40,7 +40,6 @@ export default function GenericVerifyButton(props: Props) { const currentUserQuery = api.users.me.useQuery() const userId = currentUserQuery.data?.id - // Check if user is verified developer for this emulator const verifiedDeveloperQuery = api.emulators.getVerifiedDeveloper.useQuery( { emulatorId: props.emulatorId }, { enabled: !!userId && !!props.emulatorId }, @@ -99,42 +98,31 @@ export default function GenericVerifyButton(props: Props) { const handleVerify = () => { if (isPcListing) { - verifyPcListingMutation.mutate({ + return verifyPcListingMutation.mutate({ pcListingId: props.listingId, notes: notes.trim() || undefined, }) - } else { - verifyListingMutation.mutate({ - listingId: props.listingId, - notes: notes.trim() || undefined, - }) } + verifyListingMutation.mutate({ + listingId: props.listingId, + notes: notes.trim() || undefined, + }) } const handleUnverify = () => { - if (isPcListing) { - if (props.verificationId) { - removeVerificationMutation.mutate({ - verificationId: props.verificationId, - }) - } - } else { - unverifyListingMutation.mutate({ - listingId: props.listingId, - }) + if (!isPcListing) { + return unverifyListingMutation.mutate({ listingId: props.listingId }) } + if (!props.verificationId) return + removeVerificationMutation.mutate({ verificationId: props.verificationId }) } - // Don't show button if user is not logged in if (!currentUserQuery.data) return null - // Don't show button if user is not a verified developer for this emulator if (!verifiedDeveloperQuery.data) return null - // Don't show button if user is the author (can't verify own listings) if (props.authorId === userId) return null - // Don't show button if user doesn't have at least DEVELOPER role if (!roleIncludesRole(currentUserQuery.data.role, Role.DEVELOPER)) return null const isLoading = isPcListing From aaf0c7014161fe1a3c7f93965e622d391c6b55ce Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 7 Jun 2026 18:15:33 +0200 Subject: [PATCH 2/5] Remove Supabase-specific backup script and update generic backup logic --- package.json | 1 - scripts/db-backup-supabase.sh | 120 --------------- scripts/db-backup.sh | 272 ++++++++++++++++++---------------- 3 files changed, 143 insertions(+), 250 deletions(-) delete mode 100755 scripts/db-backup-supabase.sh diff --git a/package.json b/package.json index 7cf8ae0a3..b06585ee0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "clean": "rm -rf .next && rm -rf node_modules/.cache && rm -rf .eslintcache && rm -rf tsconfig.tsbuildinfo", "clean:all": "pnpm clean && rm -rf node_modules", "db:backup": "./scripts/db-backup.sh", - "db:backup:supabase": "./scripts/db-backup-supabase.sh", "db:generate": "pnpm exec prisma generate --sql", "db:migrate:create": "./scripts/db-cmd.sh pnpm exec prisma migrate dev --create-only", "db:migrate:deploy": "./scripts/db-cmd.sh pnpm exec prisma migrate deploy", diff --git a/scripts/db-backup-supabase.sh b/scripts/db-backup-supabase.sh deleted file mode 100755 index ab0511fcc..000000000 --- a/scripts/db-backup-supabase.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/sh - -# Supabase-compatible backup script -# Creates backups in formats that can be restored to Supabase - -# Get current date for backup filename -BACKUP_DATE=$(date +"%Y%m%d_%H%M%S") -BACKUP_DIR="./backups" -BACKUP_FILE_SQL="$BACKUP_DIR/supabase_backup_$BACKUP_DATE.sql" -MAX_BACKUPS=10 # Maximum number of backups to keep - -# Create backups directory if it doesn't exist -mkdir -p $BACKUP_DIR - -# Check if a specific PostgreSQL version is available -PG_VERSION=15 # Supabase uses PostgreSQL 15 -if [ -d "/opt/homebrew/opt/postgresql@$PG_VERSION" ]; then - echo "Using PostgreSQL $PG_VERSION from Homebrew..." - export PATH="/opt/homebrew/opt/postgresql@$PG_VERSION/bin:$PATH" -elif [ -d "/usr/local/opt/postgresql@$PG_VERSION" ]; then - echo "Using PostgreSQL $PG_VERSION from Homebrew..." - export PATH="/usr/local/opt/postgresql@$PG_VERSION/bin:$PATH" -else - echo "⚠️ PostgreSQL $PG_VERSION not found, using system version" -fi - -# Use dotenv to load environment variables from .env.local -echo "Creating Supabase-compatible backup using .env.local configuration..." - -# Check pg_dump version -PG_DUMP_VERSION=$(pg_dump --version | grep -oE '[0-9]+\.[0-9]+' | head -1) -echo "Local pg_dump version: $PG_DUMP_VERSION" - -# Run pg_dump through dotenv to use environment variables from .env.local -dotenv -e .env.local -- sh -c ' - # Use DATABASE_DIRECT_URL if available, otherwise fallback to DATABASE_URL - CONNECTION_URL=${DATABASE_DIRECT_URL:-$DATABASE_URL} - - # Remove any query parameters from the connection URL - CLEAN_URL=$(echo $CONNECTION_URL | sed "s/\?.*//") - - echo "Creating Supabase-compatible SQL backup..." - - # Create a comprehensive SQL backup that Supabase can restore - # Using --no-owner and --no-privileges to avoid permission issues - # Using --if-exists for DROP statements - # Using --create to include database creation - # Using --clean to add DROP statements - pg_dump "$CLEAN_URL" \ - --no-owner \ - --no-privileges \ - --no-comments \ - --schema=public \ - --quote-all-identifiers \ - --no-tablespaces \ - --no-unlogged-table-data \ - --disable-dollar-quoting \ - --column-inserts \ - --disable-triggers \ - --if-exists \ - --clean \ - -f '"$BACKUP_FILE_SQL"' 2> /tmp/pg_dump_error - - EXIT_CODE=$? - - if [ $EXIT_CODE -ne 0 ]; then - echo "❌ Backup failed:" - cat /tmp/pg_dump_error - rm -f /tmp/pg_dump_error - exit 1 - fi - - rm -f /tmp/pg_dump_error - - # Verify the backup file was created - if [ ! -f '"$BACKUP_FILE_SQL"' ]; then - echo "❌ Backup file was not created" - exit 1 - fi - - # Check backup file size - BACKUP_SIZE=$(du -h '"$BACKUP_FILE_SQL"' | cut -f1) - echo "✅ Backup created: '"$BACKUP_FILE_SQL"' ($BACKUP_SIZE)" - - # Create a quick verification of content - echo "" - echo "📊 Backup content summary:" - echo " Tables: $(grep -c "CREATE TABLE" '"$BACKUP_FILE_SQL"' || echo "0")" - echo " Indexes: $(grep -c "CREATE INDEX" '"$BACKUP_FILE_SQL"' || echo "0")" - echo " Constraints: $(grep -c "ADD CONSTRAINT" '"$BACKUP_FILE_SQL"' || echo "0")" - echo " Total lines: $(wc -l < '"$BACKUP_FILE_SQL"')" - - exit 0 -' - -# Check if backup was successful -if [ $? -eq 0 ]; then - echo "" - echo "✅ Supabase-compatible backup completed successfully!" - echo " File: $BACKUP_FILE_SQL" - echo "" - echo "💡 To restore this backup to Supabase:" - echo " 1. Go to Supabase Dashboard > Database > Backups" - echo " 2. Use SQL Editor to run the backup file" - echo " 3. Or use: psql < $BACKUP_FILE_SQL" - - # Clean up old backups - keep only the last MAX_BACKUPS - echo "" - echo "Cleaning up old backups (keeping last $MAX_BACKUPS)..." - - NUM_BACKUPS=$(ls -1 $BACKUP_DIR/supabase_backup_*.sql 2>/dev/null | wc -l) - if [ $NUM_BACKUPS -gt $MAX_BACKUPS ]; then - NUM_TO_DELETE=$((NUM_BACKUPS - MAX_BACKUPS)) - ls -1t $BACKUP_DIR/supabase_backup_*.sql | tail -n $NUM_TO_DELETE | xargs rm -f - echo "Deleted $NUM_TO_DELETE old backup(s)" - fi -else - echo "❌ Backup failed" - exit 1 -fi \ No newline at end of file diff --git a/scripts/db-backup.sh b/scripts/db-backup.sh index a6b14a407..fbd5b459c 100755 --- a/scripts/db-backup.sh +++ b/scripts/db-backup.sh @@ -1,18 +1,99 @@ #!/bin/sh -# Get current date for backup filename +set -u + BACKUP_DATE=$(date +"%Y%m%d_%H%M%S") -BACKUP_DIR="./backups" +BACKUP_DIR="${BACKUP_DIR:-./backups}" +BACKUP_SCHEMA="${BACKUP_SCHEMA:-public}" +PG_VERSION="${PG_VERSION:-15}" + BACKUP_FILE="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.pgdump" -BACKUP_FILE_SQL="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.sql" -BACKUP_FILE_DATA="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.data.sql" -MAX_BACKUPS=10 # Maximum number of backups to keep +SHA_FILE="$BACKUP_FILE.sha256" +LOG_FILE="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.log" +TMP_BACKUP_FILE="$BACKUP_DIR/.emuready_backup_$BACKUP_DATE.pgdump.tmp" +TMP_ERROR_FILE="$BACKUP_DIR/.emuready_backup_$BACKUP_DATE.log.tmp" + +usage() { + cat <<'USAGE' +Usage: + pnpm run db:backup -- '' + +Required connection: + Use Supabase's Direct connection string: + postgresql://postgres:@db..supabase.co:5432/postgres + +Do not use: + - Supabase transaction pooler URLs on port 6543 + - Supabase session pooler URLs on pooler.supabase.com + - URLs containing pgbouncer=true + +Supabase Dashboard: + Project -> Connect -> Direct connection +USAGE +} + +fail() { + echo "❌ $1" + + if [ -s "$TMP_ERROR_FILE" ]; then + mv "$TMP_ERROR_FILE" "$LOG_FILE" + echo "Log: $LOG_FILE" + else + rm -f "$TMP_ERROR_FILE" + fi + + rm -f "$TMP_BACKUP_FILE" + exit 1 +} + +if [ "$#" -ne 1 ]; then + if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then + shift + fi +fi + +if [ "$#" -ne 1 ]; then + echo "❌ Missing required direct Postgres connection string." + echo "" + usage + exit 2 +fi + +CONNECTION_URL="$1" + +case "$CONNECTION_URL" in + *".pooler.supabase.com:"*) + echo "❌ Refusing Supabase pooler connection." + echo "" + echo "Use the Direct connection string instead:" + echo "postgresql://postgres:@db..supabase.co:5432/postgres" + exit 2 + ;; +esac + +case "$CONNECTION_URL" in + *":6543/"*) + echo "❌ Refusing transaction-pooler port 6543." + echo "" + echo "Use a direct Postgres host on port 5432 for pg_dump." + exit 2 + ;; +esac + +case "$CONNECTION_URL" in + *"pgbouncer=true"*) + echo "❌ Refusing pgbouncer=true connection string." + echo "" + echo "Use a direct Postgres connection string for pg_dump." + exit 2 + ;; +esac -# Create backups directory if it doesn't exist -mkdir -p $BACKUP_DIR +mkdir -p "$BACKUP_DIR" || { + echo "❌ Could not create backup directory: $BACKUP_DIR" + exit 1 +} -# Check if a specific PostgreSQL version is available -PG_VERSION=15 # Change this to match your server version if needed if [ -d "/opt/homebrew/opt/postgresql@$PG_VERSION" ]; then echo "Using PostgreSQL $PG_VERSION from Homebrew..." export PATH="/opt/homebrew/opt/postgresql@$PG_VERSION/bin:$PATH" @@ -21,123 +102,56 @@ elif [ -d "/usr/local/opt/postgresql@$PG_VERSION" ]; then export PATH="/usr/local/opt/postgresql@$PG_VERSION/bin:$PATH" fi -# Use dotenv to load environment variables from .env.local -echo "Running database backup using .env.local configuration..." - -# Check pg_dump version -PG_DUMP_VERSION=$(pg_dump --version | grep -oE '[0-9]+\.[0-9]+' | head -1) -echo "Local pg_dump version: $PG_DUMP_VERSION" - -# Run pg_dump through dotenv to use environment variables from .env.local -# Use the full connection string directly with pg_dump -dotenv -e .env.local -- sh -c ' - # Use DATABASE_DIRECT_URL if available, otherwise fallback to DATABASE_URL - CONNECTION_URL=${DATABASE_DIRECT_URL:-$DATABASE_URL} - - # Remove any query parameters from the connection URL - CLEAN_URL=$(echo $CONNECTION_URL | sed "s/\?.*//") - - echo "Attempting to backup database using direct connection string..." - - # Create both custom format and SQL format backups - echo "Creating custom format backup..." - pg_dump "$CLEAN_URL" -F c -f '"$BACKUP_FILE"' 2> /tmp/pg_dump_error - - if [ $? -eq 0 ]; then - echo "Creating full SQL backup with schema..." - pg_dump "$CLEAN_URL" --no-owner --no-privileges --column-inserts --schema=public --no-comments -f '"$BACKUP_FILE_SQL"' 2> /tmp/pg_dump_error_sql - - echo "Creating data-only SQL backup for existing databases..." - pg_dump "$CLEAN_URL" --no-owner --no-privileges --column-inserts --schema=public --no-comments --data-only --disable-triggers -f /tmp/backup_raw.sql 2> /tmp/pg_dump_error_data - - if [ $? -eq 0 ]; then - echo "Adding conflict resolution to SQL file..." - # Convert only INSERT statements to INSERT ... ON CONFLICT DO NOTHING - sed "s/^INSERT INTO \(.*\) VALUES \(.*\);$/INSERT INTO \1 VALUES \2 ON CONFLICT DO NOTHING;/g" /tmp/backup_raw.sql > '"$BACKUP_FILE_DATA"' - rm -f /tmp/backup_raw.sql - fi - if [ $? -ne 0 ]; then - echo "Data backup failed" - cat /tmp/pg_dump_error_data - rm -f /tmp/pg_dump_error_data - else - rm -f /tmp/pg_dump_error_data - fi - - if [ $? -ne 0 ]; then - echo "SQL backup failed, but custom format succeeded" - cat /tmp/pg_dump_error_sql - rm -f /tmp/pg_dump_error_sql - else - rm -f /tmp/pg_dump_error_sql - fi - fi - - # Check if the primary backup failed - PRIMARY_EXIT_CODE=$? - if [ $PRIMARY_EXIT_CODE -ne 0 ]; then - # Check if it was a version mismatch error - if grep -q "server version mismatch" /tmp/pg_dump_error; then - SERVER_VERSION=$(grep "server version" /tmp/pg_dump_error | grep -oE "[0-9]+\.[0-9]+" | head -1) - SERVER_MAJOR=$(echo $SERVER_VERSION | cut -d. -f1) - echo "⚠️ Version mismatch detected: Server is PostgreSQL $SERVER_VERSION but your pg_dump is version '"$PG_DUMP_VERSION"'" - echo "To fix this, you need to install PostgreSQL $SERVER_VERSION tools." - echo "" - echo "On macOS with Homebrew:" - echo " brew install postgresql@$SERVER_MAJOR" - echo " brew link --force postgresql@$SERVER_MAJOR" - echo "" - echo "On Ubuntu/Debian:" - echo " sudo apt-get install postgresql-client-$SERVER_MAJOR" - echo "" - echo "Then update PG_VERSION=$SERVER_MAJOR in this script." - echo "" - rm /tmp/pg_dump_error - exit 1 - else - cat /tmp/pg_dump_error - rm /tmp/pg_dump_error - exit 1 - fi - fi - - rm -f /tmp/pg_dump_error - exit 0 -' - -# Check if backup was successful -if [ $? -eq 0 ]; then - echo "✅ Database backup completed successfully:" - echo " Custom format: $BACKUP_FILE ($(du -h $BACKUP_FILE | cut -f1))" - if [ -f "$BACKUP_FILE_SQL" ]; then - echo " Full SQL: $BACKUP_FILE_SQL ($(du -h $BACKUP_FILE_SQL | cut -f1))" - fi - if [ -f "$BACKUP_FILE_DATA" ]; then - echo " Data-only SQL: $BACKUP_FILE_DATA ($(du -h $BACKUP_FILE_DATA | cut -f1))" - fi - echo "" - echo "💡 For new databases: use the full .sql file" - echo "💡 For existing databases: use the .data.sql file" - - # Clean up old backups - keep only the last MAX_BACKUPS of each type - echo "Cleaning up old backups (keeping last $MAX_BACKUPS)..." - - # Clean up .pgdump files - NUM_BACKUPS=$(ls -1 $BACKUP_DIR/emuready_backup_*.pgdump 2>/dev/null | wc -l) - if [ $NUM_BACKUPS -gt $MAX_BACKUPS ]; then - NUM_TO_DELETE=$((NUM_BACKUPS - MAX_BACKUPS)) - ls -1t $BACKUP_DIR/emuready_backup_*.pgdump | tail -n $NUM_TO_DELETE | xargs rm -f - echo "Deleted $NUM_TO_DELETE old .pgdump backup(s)" - fi - - # Clean up .sql files - NUM_BACKUPS=$(ls -1 $BACKUP_DIR/emuready_backup_*.sql 2>/dev/null | wc -l) - if [ $NUM_BACKUPS -gt $MAX_BACKUPS ]; then - NUM_TO_DELETE=$((NUM_BACKUPS - MAX_BACKUPS)) - ls -1t $BACKUP_DIR/emuready_backup_*.sql | tail -n $NUM_TO_DELETE | xargs rm -f - echo "Deleted $NUM_TO_DELETE old .sql backup(s)" - fi -else - echo "❌ Database backup failed" - exit 1 -fi \ No newline at end of file +command -v pg_dump >/dev/null 2>&1 || fail "pg_dump is not available" +command -v pg_restore >/dev/null 2>&1 || fail "pg_restore is not available" + +rm -f "$TMP_BACKUP_FILE" "$TMP_ERROR_FILE" +touch "$TMP_ERROR_FILE" || fail "Could not create backup log" + +{ + echo "Started: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "pg_dump: $(pg_dump --version)" + echo "schema: $BACKUP_SCHEMA" + echo "format: custom" +} >> "$TMP_ERROR_FILE" + +echo "Running database backup with explicit direct connection string..." +echo "Local pg_dump version: $(pg_dump --version)" +echo "Schema: $BACKUP_SCHEMA" + +PGSSLMODE="${PGSSLMODE:-require}" \ +PGCONNECT_TIMEOUT="${PGCONNECT_TIMEOUT:-30}" \ +PGAPPNAME="${PGAPPNAME:-emuready_db_backup}" \ +pg_dump "$CONNECTION_URL" \ + --format=custom \ + --schema="$BACKUP_SCHEMA" \ + --no-owner \ + --no-privileges \ + --no-comments \ + --file="$TMP_BACKUP_FILE" \ + 2>> "$TMP_ERROR_FILE" || fail "pg_dump failed" + +[ -s "$TMP_BACKUP_FILE" ] || fail "Backup file was not created or is empty" + +echo "Verifying backup can be fully read by pg_restore..." +pg_restore --schema="$BACKUP_SCHEMA" --file=/dev/null "$TMP_BACKUP_FILE" 2>> "$TMP_ERROR_FILE" \ + || fail "Backup verification failed" + +mv "$TMP_BACKUP_FILE" "$BACKUP_FILE" || fail "Could not finalize backup file" + +if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$BACKUP_FILE" > "$SHA_FILE" +fi + +{ + echo "Completed: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "backup: $BACKUP_FILE" +} >> "$TMP_ERROR_FILE" + +mv "$TMP_ERROR_FILE" "$LOG_FILE" + +echo "✅ Database backup completed and verified:" +echo " Custom format: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))" +[ -f "$SHA_FILE" ] && echo " SHA-256: $SHA_FILE" +echo " Log: $LOG_FILE" +echo " Cleanup: skipped; old backups are never deleted by this script" From 678497fe9630435679cef8f7018d2b237ef745ee Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 7 Jun 2026 18:17:16 +0200 Subject: [PATCH 3/5] fix: GA datalayer warning --- src/app/layout.tsx | 2 +- .../utils/sendAnalyticsEvent.test.ts | 27 +++++++++++++++++++ src/lib/analytics/utils/sendAnalyticsEvent.ts | 8 ++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 12cee3b15..b773c36d0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,9 +51,9 @@ export default function RootLayout(props: PropsWithChildren) { {env.ENABLE_ANALYTICS && ( <> + {env.GA_ID && } - {env.GA_ID && } )} {env.ENABLE_KOFI_WIDGET && } diff --git a/src/lib/analytics/utils/sendAnalyticsEvent.test.ts b/src/lib/analytics/utils/sendAnalyticsEvent.test.ts index 07b7a3757..a31dcc38c 100644 --- a/src/lib/analytics/utils/sendAnalyticsEvent.test.ts +++ b/src/lib/analytics/utils/sendAnalyticsEvent.test.ts @@ -36,6 +36,7 @@ afterEach(() => { vi.unstubAllEnvs() vi.resetModules() vi.clearAllMocks() + Reflect.deleteProperty(window, 'dataLayer') }) describe('sendAnalyticsEvent', () => { @@ -56,6 +57,31 @@ describe('sendAnalyticsEvent', () => { }) it('sends Google Analytics events when analytics are enabled', async () => { + Object.defineProperty(window, 'dataLayer', { + value: [], + configurable: true, + }) + + const { sendAnalyticsEvent } = await loadSendAnalyticsEvent({ + NODE_ENV: 'production', + NEXT_PUBLIC_APP_ENV: 'production', + NEXT_PUBLIC_ENABLE_ANALYTICS: 'true', + NEXT_PUBLIC_GA_ID: 'G-TEST', + }) + + sendAnalyticsEvent({ + category: ANALYTICS_CATEGORIES.ENGAGEMENT, + action: 'support_banner_shown', + }) + + expect(mocks.sendGAEvent).toHaveBeenCalledWith( + 'event', + 'support_banner_shown', + expect.objectContaining({ event_category: ANALYTICS_CATEGORIES.ENGAGEMENT }), + ) + }) + + it('initializes dataLayer before sending Google Analytics events', async () => { const { sendAnalyticsEvent } = await loadSendAnalyticsEvent({ NODE_ENV: 'production', NEXT_PUBLIC_APP_ENV: 'production', @@ -68,6 +94,7 @@ describe('sendAnalyticsEvent', () => { action: 'support_banner_shown', }) + expect(window.dataLayer).toEqual([]) expect(mocks.sendGAEvent).toHaveBeenCalledWith( 'event', 'support_banner_shown', diff --git a/src/lib/analytics/utils/sendAnalyticsEvent.ts b/src/lib/analytics/utils/sendAnalyticsEvent.ts index 1a367cff7..2bf866ab6 100644 --- a/src/lib/analytics/utils/sendAnalyticsEvent.ts +++ b/src/lib/analytics/utils/sendAnalyticsEvent.ts @@ -4,6 +4,12 @@ import { env } from '@/lib/env' import { logger } from '@/lib/logger' import { isTrackingAllowed } from './isTrackingAllowed' +function ensureGoogleAnalyticsDataLayer() { + if (typeof window === 'undefined') return + + if (!window.dataLayer) window.dataLayer = [] +} + export function sendAnalyticsEvent(params: AnalyticsEventData) { if (!isTrackingAllowed(params.category)) return @@ -53,6 +59,8 @@ export function sendAnalyticsEvent(params: AnalyticsEventData) { if (typeof window !== 'undefined' && env.ENABLE_ANALYTICS) { if (env.GA_ID) { + ensureGoogleAnalyticsDataLayer() + sendGAEvent('event', params.action, { event_category: params.category, ...eventData, From bf4736cc28e3a31ffdb7053b37e098781df1f8e8 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 7 Jun 2026 18:19:46 +0200 Subject: [PATCH 4/5] fix: update cache strategy and error handling in retrocatalog route --- .../[brandName]/[modelName]/route.test.ts | 22 ++++++++++++++++++- .../[brandName]/[modelName]/route.ts | 18 +++++++++++---- .../retrocatalog/RetroCatalogButton.tsx | 10 ++++----- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts b/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts index 2c53d2722..71e5d125d 100644 --- a/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts +++ b/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts @@ -2,6 +2,12 @@ import { NextRequest } from 'next/server' import { afterEach, describe, expect, it, vi } from 'vitest' import { GET } from './route' +vi.mock('@/lib/logger', () => ({ + logger: { + warn: vi.fn(), + }, +})) + const request = new NextRequest('http://localhost/api/retrocatalog/Retroid/Pocket%205') function contextFor(brandName: string, modelName: string) { @@ -21,6 +27,7 @@ describe('/api/retrocatalog/[brandName]/[modelName]', () => { const response = await GET(request, contextFor('Retroid', 'Pocket 5')) expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') expect(await response.json()).toEqual([]) }) @@ -36,6 +43,9 @@ describe('/api/retrocatalog/[brandName]/[modelName]', () => { const response = await GET(request, contextFor('Retroid', 'Pocket 5')) expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe( + 'public, s-maxage=86400, stale-while-revalidate=3600', + ) expect(await response.json()).toEqual([device]) }) @@ -49,11 +59,21 @@ describe('/api/retrocatalog/[brandName]/[modelName]', () => { 'https://retrocatalog.com/api/catalog/retro-handhelds/Retro%2Fid/Pocket%205%3Fx%3D1', { headers: { Accept: 'application/json' }, - next: { revalidate: 86400 }, + cache: 'no-store', }, ) }) + it('does not cache empty RetroCatalog matches', async () => { + vi.stubGlobal('fetch', async () => Response.json([])) + + const response = await GET(request, contextFor('Retroid', 'Pocket 6')) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(await response.json()).toEqual([]) + }) + it('does not call RetroCatalog for invalid lookup parameters', async () => { const fetch = vi.fn(async () => Response.json([])) vi.stubGlobal('fetch', fetch) diff --git a/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts b/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts index 89f517d27..333400e6b 100644 --- a/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts +++ b/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts @@ -18,7 +18,7 @@ function isCatalogSegment(value: string) { } function emptyCatalogResponse() { - return NextResponse.json([], { headers: CATALOG_CACHE_HEADERS }) + return NextResponse.json([], { headers: { 'Cache-Control': 'no-store' } }) } function catalogUrl(brandName: string, modelName: string) { @@ -38,12 +38,22 @@ export async function GET( try { const response = await fetch(catalogUrl(brandName, modelName), { headers: { Accept: 'application/json' }, - next: { revalidate: 86400 }, + cache: 'no-store', }) - if (!response.ok) return emptyCatalogResponse() + if (!response.ok) { + logger.warn('[retrocatalog] Device lookup rejected', { + status: response.status, + brandName, + modelName, + }) + + return emptyCatalogResponse() + } + + const data: unknown = await response.json() + if (!Array.isArray(data) || data.length === 0) return emptyCatalogResponse() - const data = await response.json() return NextResponse.json(data, { headers: CATALOG_CACHE_HEADERS }) } catch (error) { logger.warn('[retrocatalog] Device lookup failed', { diff --git a/src/components/retrocatalog/RetroCatalogButton.tsx b/src/components/retrocatalog/RetroCatalogButton.tsx index 2621012ac..b5e906009 100644 --- a/src/components/retrocatalog/RetroCatalogButton.tsx +++ b/src/components/retrocatalog/RetroCatalogButton.tsx @@ -25,17 +25,15 @@ interface Props { } /** - * RetroCatalog specs button - shows only when device exists on RetroCatalog - * Opens device specs in new tab with tasteful hover animations + * RetroCatalog specs button + * shows only when device exists on RetroCatalog + * Opens device specs in new tab */ export function RetroCatalogButton(props: Props) { const { deviceId, brandName, modelName, variant = 'pill' } = props const [isHovered, setIsHovered] = useState(false) - const { exists, url, isLoading } = useRetroCatalogDevice({ - brandName, - modelName, - }) + const { exists, url, isLoading } = useRetroCatalogDevice({ brandName, modelName }) if (isLoading || !exists || !url) return null From 446bde7f9c76d3e47a61826bfbb5400a7811a7ce Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 8 Jun 2026 13:23:28 +0200 Subject: [PATCH 5/5] feat: add PC processed listings page and related functionality --- src/app/admin/components/AdminNavIcon.tsx | 1 + ...avigation.tsx => AdminQuickNavigation.tsx} | 9 +- .../ApprovalStatusOverrideModal.tsx | 90 ++++ .../ProcessedReportsAdminPage.tsx | 223 +++++++++ .../ProcessedReportsTable.tsx | 335 ++++++++++++++ .../components/processed-reports/index.ts | 2 + .../components/processed-reports/types.ts | 78 ++++ src/app/admin/config/routes.ts | 1 + src/app/admin/dashboard/AdminDashboard.tsx | 8 +- src/app/admin/data.ts | 10 +- src/app/admin/pc-processed-listings/page.tsx | 171 +++++++ .../components/OverrideStatusModal.tsx | 81 ---- src/app/admin/processed-listings/page.tsx | 424 +++++------------- src/data/storageKeys.ts | 3 +- src/schemas/pcListing.ts | 31 +- src/server/api/routers/listings/admin.test.ts | 236 +++++++++- src/server/api/routers/listings/admin.ts | 115 ++++- src/server/api/routers/pcListings.test.ts | 156 ++++++- src/server/api/routers/pcListings.ts | 167 ++++++- src/server/api/utils/pcListingHelpers.ts | 41 ++ src/server/api/utils/processedStatusTrust.ts | 27 ++ src/server/notifications/eventEmitter.ts | 1 - src/server/notifications/service.ts | 6 +- 23 files changed, 1765 insertions(+), 451 deletions(-) rename src/app/admin/components/{QuickNavigation/QuickNavigation.tsx => AdminQuickNavigation.tsx} (90%) create mode 100644 src/app/admin/components/processed-reports/ApprovalStatusOverrideModal.tsx create mode 100644 src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx create mode 100644 src/app/admin/components/processed-reports/ProcessedReportsTable.tsx create mode 100644 src/app/admin/components/processed-reports/index.ts create mode 100644 src/app/admin/components/processed-reports/types.ts create mode 100644 src/app/admin/pc-processed-listings/page.tsx delete mode 100644 src/app/admin/processed-listings/components/OverrideStatusModal.tsx create mode 100644 src/server/api/utils/processedStatusTrust.ts diff --git a/src/app/admin/components/AdminNavIcon.tsx b/src/app/admin/components/AdminNavIcon.tsx index 495a1d282..73a2d2526 100644 --- a/src/app/admin/components/AdminNavIcon.tsx +++ b/src/app/admin/components/AdminNavIcon.tsx @@ -36,6 +36,7 @@ const getAdminNavIcon = (href: string, className: string) => { if (href.includes(ADMIN_ROUTES.API_ACCESS_DEV)) return if (href.includes(ADMIN_ROUTES.API_ACCESS)) return if (href.includes(ADMIN_ROUTES.MANAGE_LISTINGS)) return + if (href.includes(ADMIN_ROUTES.PC_PROCESSED_LISTINGS)) return if (href.includes(ADMIN_ROUTES.PROCESSED_LISTINGS)) return if (href.includes(ADMIN_ROUTES.REPORTS)) return if (href.includes(ADMIN_ROUTES.USER_BANS)) return diff --git a/src/app/admin/components/QuickNavigation/QuickNavigation.tsx b/src/app/admin/components/AdminQuickNavigation.tsx similarity index 90% rename from src/app/admin/components/QuickNavigation/QuickNavigation.tsx rename to src/app/admin/components/AdminQuickNavigation.tsx index ba616b1b1..bf6b4916d 100644 --- a/src/app/admin/components/QuickNavigation/QuickNavigation.tsx +++ b/src/app/admin/components/AdminQuickNavigation.tsx @@ -4,17 +4,17 @@ import { ChevronDown, ChevronUp } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { cn } from '@/lib/utils' -import { type AdminNavItem } from '../../data' -import ApprovalCountBadge from '../ApprovalCountBadge' +import { type AdminNavItem } from '../data' +import ApprovalCountBadge from './ApprovalCountBadge' -interface QuickNavigationProps { +interface Props { items: AdminNavItem[] title: string defaultExpanded?: boolean className?: string } -export function QuickNavigation(props: QuickNavigationProps) { +export function AdminQuickNavigation(props: Props) { const defaultExpanded = props.defaultExpanded ?? true const [isExpanded, setIsExpanded] = useState(defaultExpanded) @@ -51,7 +51,6 @@ export function QuickNavigation(props: QuickNavigationProps) { {isExpanded && (
- {/* Responsive grid that adjusts based on screen size */}
{props.items.map((item) => ( void + title: string + currentStatus: ApprovalStatus | null + newStatus: ApprovalStatus | null + overrideNotes: string + onOverrideNotesChange: (notes: string) => void + onSubmit: () => void + isLoading: boolean +} + +export function ApprovalStatusOverrideModal(props: Props) { + if (!props.currentStatus || !props.newStatus) return null + + const isReturningToPending = props.newStatus === ApprovalStatus.PENDING + + return ( + +
+

+ Current Status:{' '} + + {props.currentStatus} + +
+ New Status:{' '} + + {props.newStatus} + +

+ {isReturningToPending ? ( +

+ This returns the report to the review queue and clears its processed admin, processed + date, and processed notes. +

+ ) : ( +
+ + props.onOverrideNotesChange(ev.target.value)} + rows={4} + placeholder={`Notes for changing status to ${props.newStatus}...`} + className="w-full mt-1" + /> +
+ )} +
+ + +
+
+
+ ) +} diff --git a/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx b/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx new file mode 100644 index 000000000..a978e312e --- /dev/null +++ b/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx @@ -0,0 +1,223 @@ +'use client' + +import { useMemo, useState, type ChangeEvent } from 'react' +import { + AdminErrorState, + AdminPageLayout, + AdminSearchFilters, + AdminStatsDisplay, + AdminTableContainer, + AdminTableNoResults, +} from '@/components/admin' +import { + ColumnVisibilityControl, + DisplayToggleButton, + LoadingSpinner, + Pagination, + SelectInput, +} from '@/components/ui' +import storageKeys from '@/data/storageKeys' +import { + useColumnVisibility, + useEmulatorLogos, + useLocalStorage, + type ColumnDefinition, +} from '@/hooks' +import { hasPermission, PERMISSIONS } from '@/utils/permission-system' +import { hasRolePermission } from '@/utils/permissions' +import { ApprovalStatus, Role } from '@orm' +import { ApprovalStatusOverrideModal } from './ApprovalStatusOverrideModal' +import { ProcessedReportsTable } from './ProcessedReportsTable' +import { type ProcessedReportHardwareColumn, type ProcessedReportsAdminPageProps } from './types' + +const STATUS_FILTER_OPTIONS = [ + { id: 'all' as const, name: 'All Processed' }, + { id: ApprovalStatus.APPROVED, name: 'Approved' }, + { id: ApprovalStatus.REJECTED, name: 'Rejected' }, +] + +function buildColumns( + hardwareColumns: ProcessedReportHardwareColumn[], +): ColumnDefinition[] { + return [ + { key: 'game', label: 'Game', defaultVisible: true }, + { key: 'system', label: 'System', defaultVisible: true }, + ...hardwareColumns.map((column) => ({ + key: column.key, + label: column.label, + defaultVisible: column.defaultVisible, + })), + { key: 'emulator', label: 'Emulator', defaultVisible: true }, + { key: 'author', label: 'Author', defaultVisible: true }, + { key: 'status', label: 'Status', defaultVisible: true }, + { key: 'processedBy', label: 'Processed By', defaultVisible: true }, + { key: 'processedAt', label: 'Processed At', defaultVisible: true }, + { key: 'actions', label: 'Actions', alwaysVisible: true }, + ] +} + +export function ProcessedReportsAdminPage( + props: ProcessedReportsAdminPageProps, +) { + const columns = useMemo(() => buildColumns(props.hardwareColumns), [props.hardwareColumns]) + const columnVisibility = useColumnVisibility(columns, { storageKey: props.storageKey }) + const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( + storageKeys.showSystemIcons, + true, + ) + const emulatorLogos = useEmulatorLogos() + const [showOverrideModal, setShowOverrideModal] = useState(false) + const [selectedReport, setSelectedReport] = useState(null) + const [overrideNotes, setOverrideNotes] = useState('') + const [newStatusForOverride, setNewStatusForOverride] = useState(null) + + const handleFilterChange = (ev: ChangeEvent) => { + const value = ev.target.value as ApprovalStatus | 'all' + props.onFilterStatusChange(value === 'all' ? null : value) + props.table.setPage(1) + } + + const openOverrideModal = (report: TReport, targetStatus: ApprovalStatus) => { + setSelectedReport(report) + setNewStatusForOverride(targetStatus) + setOverrideNotes(props.accessors.getProcessedNotes(report) ?? '') + setShowOverrideModal(true) + } + + const closeOverrideModal = () => { + setShowOverrideModal(false) + setSelectedReport(null) + setOverrideNotes('') + setNewStatusForOverride(null) + } + + const handleOverrideSubmit = () => { + if (!selectedReport || !newStatusForOverride) return + + void props + .onOverrideStatus({ + report: selectedReport, + newStatus: newStatusForOverride, + overrideNotes: + newStatusForOverride === ApprovalStatus.PENDING ? undefined : overrideNotes || undefined, + }) + .then(closeOverrideModal) + .catch(() => undefined) + } + + if (props.errorMessage) { + return + } + + const canEditReports = hasPermission(props.currentUserPermissions, PERMISSIONS.EDIT_ANY_LISTING) + const canOverrideReports = hasRolePermission(props.currentUserRole, Role.SUPER_ADMIN) + const canViewUsers = hasPermission(props.currentUserPermissions, PERMISSIONS.MANAGE_USERS) + const selectedReportTitle = selectedReport ? props.accessors.getGameTitle(selectedReport) : '' + const overrideModalTitle = + newStatusForOverride === ApprovalStatus.PENDING + ? `Return to Pending Review: ${selectedReportTitle}` + : `Override Status: ${selectedReportTitle}` + + return ( + + setShowSystemIcons(!showSystemIcons)} + isHydrated={isSystemIconsHydrated} + logoLabel="Show System Icons" + nameLabel="Show System Names" + /> + + + + } + > + + + + table={props.table} + searchPlaceholder={props.searchPlaceholder} + onClear={() => props.onFilterStatusChange(null)} + > + + + + + {props.isReportsLoading ? ( + + ) : props.reports.length === 0 ? ( + + ) : ( + + )} + + + {props.pagination && props.pagination.pages > 1 && ( +
+ +
+ )} + + +
+ ) +} diff --git a/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx b/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx new file mode 100644 index 000000000..6ccaa01dd --- /dev/null +++ b/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx @@ -0,0 +1,335 @@ +'use client' + +import { ExternalLink } from 'lucide-react' +import Link from 'next/link' +import { type UseAdminTableReturn } from '@/app/admin/hooks' +import { EmulatorIcon, SystemIcon } from '@/components/icons' +import { + ApproveButton, + EditButton, + LocalizedDate, + RejectButton, + SortableHeader, + Tooltip, + TooltipContent, + TooltipTrigger, + UndoButton, + ViewButton, + ViewUserButton, +} from '@/components/ui' +import { type UseColumnVisibilityReturn } from '@/hooks' +import analytics from '@/lib/analytics' +import { getApprovalStatusColor } from '@/utils/badge-colors' +import { ApprovalStatus } from '@orm' +import { type ProcessedReportAccessors, type ProcessedReportHardwareColumn } from './types' + +interface Props { + table: UseAdminTableReturn + reports: TReport[] + hardwareColumns: ProcessedReportHardwareColumn[] + columnVisibility: UseColumnVisibilityReturn + accessors: ProcessedReportAccessors + reportLabel: string + analyticsContext: string + showSystemIcons: boolean + isSystemIconsHydrated: boolean + showEmulatorLogos: boolean + isEmulatorLogosHydrated: boolean + canEditReports: boolean + canOverrideReports: boolean + canViewUsers: boolean + isOverridePending: boolean + onOpenOverrideModal: (report: TReport, targetStatus: ApprovalStatus) => void +} + +export function ProcessedReportsTable( + props: Props, +) { + return ( +
+ + + + {props.columnVisibility.isColumnVisible('game') && ( + + )} + {props.columnVisibility.isColumnVisible('system') && ( + + )} + {props.hardwareColumns.map( + (column) => + props.columnVisibility.isColumnVisible(column.key) && ( + + ), + )} + {props.columnVisibility.isColumnVisible('emulator') && ( + + )} + {props.columnVisibility.isColumnVisible('author') && ( + + )} + {props.columnVisibility.isColumnVisible('status') && ( + + )} + {props.columnVisibility.isColumnVisible('processedBy') && ( + + )} + {props.columnVisibility.isColumnVisible('processedAt') && ( + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + + )} + + + + {props.reports.map((report) => ( + + ))} + +
+ Processed By + + Actions +
+
+ ) +} + +interface RowProps { + report: TReport + hardwareColumns: ProcessedReportHardwareColumn[] + columnVisibility: UseColumnVisibilityReturn + accessors: ProcessedReportAccessors + reportLabel: string + analyticsContext: string + showSystemIcons: boolean + isSystemIconsHydrated: boolean + showEmulatorLogos: boolean + isEmulatorLogosHydrated: boolean + canEditReports: boolean + canOverrideReports: boolean + canViewUsers: boolean + isOverridePending: boolean + onOpenOverrideModal: (report: TReport, targetStatus: ApprovalStatus) => void +} + +function ProcessedReportRow( + props: RowProps, +) { + const reportId = props.accessors.getId(props.report) + const reportHref = props.accessors.getViewHref(props.report) + const author = props.accessors.getAuthor(props.report) + const processedAt = props.accessors.getProcessedAt(props.report) + const status = props.accessors.getStatus(props.report) + const gameTitle = props.accessors.getGameTitle(props.report) + const systemName = props.accessors.getSystemName(props.report) + const systemKey = props.accessors.getSystemKey?.(props.report) + const emulatorName = props.accessors.getEmulatorName(props.report) + const emulatorLogo = props.accessors.getEmulatorLogo(props.report) + + return ( + + {props.columnVisibility.isColumnVisible('game') && ( + + { + analytics.contentDiscovery.externalLinkClicked({ + url: reportHref, + context: props.analyticsContext, + entityId: reportId, + }) + }} + > + {gameTitle} + + + + )} + {props.columnVisibility.isColumnVisible('system') && ( + + {props.isSystemIconsHydrated && props.showSystemIcons && systemKey ? ( +
+ + {systemName} +
+ ) : ( + systemName + )} + + )} + {props.hardwareColumns.map( + (column) => + props.columnVisibility.isColumnVisible(column.key) && ( + + {column.render(props.report)} + + ), + )} + {props.columnVisibility.isColumnVisible('emulator') && ( + + + + )} + {props.columnVisibility.isColumnVisible('author') && ( + + {author ? ( + + {author.name ?? 'N/A'} + + ) : ( + 'N/A' + )} + + )} + {props.columnVisibility.isColumnVisible('status') && ( + + + {status} + + + )} + {props.columnVisibility.isColumnVisible('processedBy') && ( + + {props.accessors.getProcessedByName(props.report) ?? 'N/A'} + + )} + {props.columnVisibility.isColumnVisible('processedAt') && ( + + {processedAt ? ( + + + + + + + + + + + ) : ( + 'N/A' + )} + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + +
+ {props.canEditReports && ( + + )} + {props.canOverrideReports && ( + props.onOpenOverrideModal(props.report, ApprovalStatus.PENDING)} + disabled={props.isOverridePending} + /> + )} + {props.canOverrideReports && status === ApprovalStatus.APPROVED && ( + props.onOpenOverrideModal(props.report, ApprovalStatus.REJECTED)} + disabled={props.isOverridePending} + /> + )} + {props.canOverrideReports && status === ApprovalStatus.REJECTED && ( + props.onOpenOverrideModal(props.report, ApprovalStatus.APPROVED)} + disabled={props.isOverridePending} + /> + )} + {props.canViewUsers && author && ( + + )} + +
+ + )} + + ) +} diff --git a/src/app/admin/components/processed-reports/index.ts b/src/app/admin/components/processed-reports/index.ts new file mode 100644 index 000000000..e96fa1e0c --- /dev/null +++ b/src/app/admin/components/processed-reports/index.ts @@ -0,0 +1,2 @@ +export { ProcessedReportsAdminPage } from './ProcessedReportsAdminPage' +export type { ProcessedReportAccessors, ProcessedReportHardwareColumn } from './types' diff --git a/src/app/admin/components/processed-reports/types.ts b/src/app/admin/components/processed-reports/types.ts new file mode 100644 index 000000000..8794f5275 --- /dev/null +++ b/src/app/admin/components/processed-reports/types.ts @@ -0,0 +1,78 @@ +import type { UseAdminTableReturn } from '@/app/admin/hooks' +import type { ApprovalStatus, Role } from '@orm' +import type { ReactNode } from 'react' + +export interface ProcessedReportPagination { + page: number + pages: number + total: number + limit?: number +} + +export interface ProcessedReportStats { + total?: number + approved?: number + pending?: number + rejected?: number +} + +export interface ProcessedReportUser { + id: string + name?: string | null +} + +export interface ProcessedReportHardwareColumn { + key: string + label: string + sortField: TSortField + defaultVisible?: boolean + render: (report: TReport) => ReactNode +} + +export interface ProcessedReportAccessors { + getId: (report: TReport) => string + getGameTitle: (report: TReport) => string + getSystemName: (report: TReport) => string + getSystemKey?: (report: TReport) => string | null | undefined + getEmulatorName: (report: TReport) => string + getEmulatorLogo: (report: TReport) => string | null | undefined + getAuthor: (report: TReport) => ProcessedReportUser | null | undefined + getProcessedByName: (report: TReport) => string | null | undefined + getProcessedAt: (report: TReport) => Date | string | null | undefined + getProcessedNotes: (report: TReport) => string | null | undefined + getStatus: (report: TReport) => ApprovalStatus + getEditHref: (report: TReport) => string + getViewHref: (report: TReport) => string +} + +export interface ProcessedReportOverrideRequest { + report: TReport + newStatus: ApprovalStatus + overrideNotes?: string +} + +export interface ProcessedReportsAdminPageProps { + title: string + description: string + reportLabel: string + loadingText: string + errorMessage: string | null + searchPlaceholder: string + storageKey: string + analyticsContext: string + table: UseAdminTableReturn + reports: TReport[] + pagination?: ProcessedReportPagination + stats: ProcessedReportStats + isStatsLoading: boolean + isReportsLoading: boolean + currentUserPermissions?: string[] | null + currentUserRole?: Role | null + filterStatus: ApprovalStatus | null + hardwareColumns: ProcessedReportHardwareColumn[] + accessors: ProcessedReportAccessors + onFilterStatusChange: (status: ApprovalStatus | null) => void + onRetry: () => void + onOverrideStatus: (request: ProcessedReportOverrideRequest) => Promise + isOverridePending: boolean +} diff --git a/src/app/admin/config/routes.ts b/src/app/admin/config/routes.ts index 82afb1d43..d9f92b45b 100644 --- a/src/app/admin/config/routes.ts +++ b/src/app/admin/config/routes.ts @@ -45,6 +45,7 @@ export const ADMIN_ROUTES = { // Listings MANAGE_LISTINGS: '/admin/listings', PROCESSED_LISTINGS: '/admin/processed-listings', + PC_PROCESSED_LISTINGS: '/admin/pc-processed-listings', // Custom Fields FIELD_TEMPLATES: '/admin/custom-field-templates', diff --git a/src/app/admin/dashboard/AdminDashboard.tsx b/src/app/admin/dashboard/AdminDashboard.tsx index 75629c9ac..08561bcca 100644 --- a/src/app/admin/dashboard/AdminDashboard.tsx +++ b/src/app/admin/dashboard/AdminDashboard.tsx @@ -2,8 +2,8 @@ import { Users, FileText, MessageSquare, AlertTriangle, Ban } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' +import { AdminQuickNavigation } from '@/app/admin/components/AdminQuickNavigation' import { ErrorBoundary } from '@/app/admin/components/ErrorBoundary' -import { QuickNavigation } from '@/app/admin/components/QuickNavigation/QuickNavigation' import { ADMIN_ROUTES } from '@/app/admin/config/routes' import { type AdminNavItem } from '@/app/admin/data' import { api } from '@/lib/api' @@ -104,7 +104,11 @@ export function AdminDashboard(props: Props) {
{/* Quick Navigation - Collapsible */} - + {/* Show error banner if API call failed */} diff --git a/src/app/admin/data.ts b/src/app/admin/data.ts index 7f07ab163..750135e6a 100644 --- a/src/app/admin/data.ts +++ b/src/app/admin/data.ts @@ -136,9 +136,15 @@ export const superAdminNavItems: AdminNavItem[] = [ }, { href: ADMIN_ROUTES.PROCESSED_LISTINGS, - label: 'Processed Listings', + label: 'Processed Reports', exact: true, - description: 'View all processed listings.', + description: 'View approved and rejected handheld reports.', + }, + { + href: ADMIN_ROUTES.PC_PROCESSED_LISTINGS, + label: 'PC Processed Reports', + exact: true, + description: 'View approved and rejected PC compatibility reports.', }, { href: ADMIN_ROUTES.REPORTS, diff --git a/src/app/admin/pc-processed-listings/page.tsx b/src/app/admin/pc-processed-listings/page.tsx new file mode 100644 index 000000000..c32b0abbd --- /dev/null +++ b/src/app/admin/pc-processed-listings/page.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState } from 'react' +import { + type ProcessedReportAccessors, + ProcessedReportsAdminPage, + type ProcessedReportHardwareColumn, +} from '@/app/admin/components/processed-reports' +import { useAdminTable } from '@/app/admin/hooks' +import storageKeys from '@/data/storageKeys' +import { api } from '@/lib/api' +import { logger } from '@/lib/logger' +import toast from '@/lib/toast' +import { type RouterInput, type RouterOutput } from '@/types/trpc' +import getErrorMessage from '@/utils/getErrorMessage' +import { ApprovalStatus } from '@orm' + +type ProcessedPcListing = RouterOutput['pcListings']['getProcessed']['pcListings'][number] +type ProcessedPcListingSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'cpu' + | 'gpu' + | 'emulator.name' + | 'author.name' + +function getGpuLabel(listing: ProcessedPcListing): string { + return listing.gpu ? `${listing.gpu.brand.name} ${listing.gpu.modelName}` : 'Integrated / N/A' +} + +const PC_HARDWARE_COLUMNS: ProcessedReportHardwareColumn< + ProcessedPcListing, + ProcessedPcListingSortField +>[] = [ + { + key: 'cpu', + label: 'CPU', + sortField: 'cpu', + defaultVisible: true, + render: (listing) => `${listing.cpu.brand.name} ${listing.cpu.modelName}`, + }, + { + key: 'gpu', + label: 'GPU', + sortField: 'gpu', + defaultVisible: true, + render: getGpuLabel, + }, +] + +const PC_REPORT_ACCESSORS: ProcessedReportAccessors = { + getId: (listing) => listing.id, + getGameTitle: (listing) => listing.game.title, + getSystemName: (listing) => listing.game.system.name, + getSystemKey: (listing) => listing.game.system.key, + getEmulatorName: (listing) => listing.emulator.name, + getEmulatorLogo: (listing) => listing.emulator.logo, + getAuthor: (listing) => listing.author, + getProcessedByName: (listing) => listing.processedByUser?.name, + getProcessedAt: (listing) => listing.processedAt, + getProcessedNotes: (listing) => listing.processedNotes, + getStatus: (listing) => listing.status, + getEditHref: (listing) => `/admin/pc-listings/${listing.id}/edit`, + getViewHref: (listing) => `/pc-listings/${listing.id}`, +} + +function PcProcessedListingsPage() { + const table = useAdminTable({ + defaultLimit: 20, + defaultSortField: 'processedAt', + defaultSortDirection: 'desc', + }) + + const [filterStatus, setFilterStatus] = useState(null) + const currentUserQuery = api.users.me.useQuery() + const pcListingsStatsQuery = api.pcListings.stats.useQuery() + const processedPcListingsQuery = api.pcListings.getProcessed.useQuery({ + page: table.page, + limit: table.limit, + filterStatus: filterStatus ?? null, + search: table.debouncedSearch || null, + sortField: table.sortField ?? null, + sortDirection: table.sortDirection ?? null, + }) + + const utils = api.useUtils() + const invalidateAdminPcListingViews = async () => { + await Promise.all([ + utils.pcListings.getProcessed.invalidate(), + utils.pcListings.pending.invalidate(), + utils.pcListings.get.invalidate(), + utils.pcListings.stats.invalidate(), + ]) + } + + const overrideMutation = api.pcListings.overrideStatus.useMutation({ + onSuccess: async () => { + toast.success('PC report status updated.') + await invalidateAdminPcListingViews() + }, + onError: (err) => { + logger.error('Failed to override PC report status:', err) + toast.error(`Failed to override PC report status: ${getErrorMessage(err)}`) + }, + }) + + const resetToPendingMutation = api.pcListings.resetToPending.useMutation({ + onSuccess: async () => { + toast.success('PC report returned to pending review.') + await invalidateAdminPcListingViews() + }, + onError: (err) => { + logger.error('Failed to return PC report to pending review:', err) + toast.error(`Failed to return PC report to pending review: ${getErrorMessage(err)}`) + }, + }) + + const processedPcListings = processedPcListingsQuery.data?.pcListings ?? [] + + return ( + + title="PC Processed Reports" + description="Review approved and rejected PC compatibility reports. SUPER_ADMINs can override these decisions." + reportLabel="PC Compatibility Report" + loadingText="Loading processed PC reports..." + errorMessage={ + processedPcListingsQuery.error + ? `Error loading processed PC reports: ${processedPcListingsQuery.error.message}` + : null + } + searchPlaceholder="Search by game, system, CPU, GPU, author, emulator, or notes..." + storageKey={storageKeys.columnVisibility.adminPcProcessedListings} + analyticsContext="admin_processed_pc_reports_view" + table={table} + reports={processedPcListings} + pagination={processedPcListingsQuery.data?.pagination} + stats={pcListingsStatsQuery.data ?? {}} + isStatsLoading={pcListingsStatsQuery.isPending} + isReportsLoading={processedPcListingsQuery.isPending} + currentUserPermissions={currentUserQuery.data?.permissions} + currentUserRole={currentUserQuery.data?.role} + filterStatus={filterStatus} + hardwareColumns={PC_HARDWARE_COLUMNS} + accessors={PC_REPORT_ACCESSORS} + onFilterStatusChange={setFilterStatus} + onRetry={() => { + void processedPcListingsQuery.refetch() + }} + onOverrideStatus={async (request) => { + if (request.newStatus === ApprovalStatus.PENDING) { + await resetToPendingMutation.mutateAsync({ + pcListingId: request.report.id, + } satisfies RouterInput['pcListings']['resetToPending']) + return + } + + await overrideMutation.mutateAsync({ + pcListingId: request.report.id, + newStatus: request.newStatus, + overrideNotes: request.overrideNotes, + } satisfies RouterInput['pcListings']['overrideStatus']) + }} + isOverridePending={overrideMutation.isPending || resetToPendingMutation.isPending} + /> + ) +} + +export default PcProcessedListingsPage diff --git a/src/app/admin/processed-listings/components/OverrideStatusModal.tsx b/src/app/admin/processed-listings/components/OverrideStatusModal.tsx deleted file mode 100644 index 5bf2b460e..000000000 --- a/src/app/admin/processed-listings/components/OverrideStatusModal.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Button, Input, Modal } from '@/components/ui' -import { type RouterOutput } from '@/types/trpc' -import { getApprovalStatusColor } from '@/utils/badge-colors' -import { ApprovalStatus } from '@orm' - -type ProcessedListing = RouterOutput['listings']['getProcessed']['listings'][number] - -interface Props { - isOpen: boolean - onClose: () => void - selectedListing: ProcessedListing | null - newStatus: ApprovalStatus | null - overrideNotes: string - setOverrideNotes: (notes: string) => void - onSubmit: () => void - isLoading: boolean -} - -function OverrideStatusModal(props: Props) { - if (!props.selectedListing || !props.newStatus) return null - - return ( - -
-

- Current Status:{' '} - - {props.selectedListing.status} - -
- New Status:{' '} - - {props.newStatus} - -

-
- - props.setOverrideNotes(ev.target.value)} - rows={4} - placeholder={`Notes for changing status to ${props.newStatus}...`} - className="w-full mt-1" - /> -
-
- - -
-
-
- ) -} - -export default OverrideStatusModal diff --git a/src/app/admin/processed-listings/page.tsx b/src/app/admin/processed-listings/page.tsx index fb3b6dc44..2d8f3d352 100644 --- a/src/app/admin/processed-listings/page.tsx +++ b/src/app/admin/processed-listings/page.tsx @@ -1,348 +1,158 @@ 'use client' -import { ExternalLink } from 'lucide-react' -import Link from 'next/link' -import { useState, type ChangeEvent } from 'react' -import { useAdminTable } from '@/app/admin/hooks' -import { - AdminPageLayout, - AdminSearchFilters, - AdminStatsDisplay, - AdminTableContainer, - AdminTableNoResults, -} from '@/components/admin' +import { useState } from 'react' import { - ApproveButton, - ColumnVisibilityControl, - EditButton, - LoadingSpinner, - Pagination, - RejectButton, - SelectInput, - LocalizedDate, - UndoButton, -} from '@/components/ui' + type ProcessedReportAccessors, + ProcessedReportsAdminPage, + type ProcessedReportHardwareColumn, +} from '@/app/admin/components/processed-reports' +import { useAdminTable } from '@/app/admin/hooks' import storageKeys from '@/data/storageKeys' -import { useColumnVisibility, type ColumnDefinition } from '@/hooks' -import analytics from '@/lib/analytics' import { api } from '@/lib/api' +import { logger } from '@/lib/logger' import toast from '@/lib/toast' -import { type RouterOutput, type RouterInput } from '@/types/trpc' -import { getApprovalStatusColor } from '@/utils/badge-colors' +import { type RouterInput, type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' -import { hasPermission, PERMISSIONS } from '@/utils/permission-system' import { ApprovalStatus } from '@orm' -import OverrideStatusModal from './components/OverrideStatusModal' type ProcessedListing = RouterOutput['listings']['getProcessed']['listings'][number] - -const statusOptions = [ - { id: 'all' as const, name: 'All Processed' }, - { id: ApprovalStatus.APPROVED, name: 'Approved' }, - { id: ApprovalStatus.PENDING, name: 'Pending' }, - { id: ApprovalStatus.REJECTED, name: 'Rejected' }, +type ProcessedListingSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'device' + | 'emulator.name' + | 'author.name' + +const HANDHELD_HARDWARE_COLUMNS: ProcessedReportHardwareColumn< + ProcessedListing, + ProcessedListingSortField +>[] = [ + { + key: 'device', + label: 'Device', + sortField: 'device', + defaultVisible: true, + render: (listing) => `${listing.device.brand.name} ${listing.device.modelName}`, + }, ] -const PROCESSED_LISTINGS_COLUMNS: ColumnDefinition[] = [ - { key: 'game', label: 'Game / System', defaultVisible: true }, - { key: 'author', label: 'Author', defaultVisible: true }, - { key: 'status', label: 'Status', defaultVisible: true }, - { key: 'processedBy', label: 'Processed By (Admin)', defaultVisible: true }, - { key: 'processedAt', label: 'Processed At', defaultVisible: true }, - { key: 'actions', label: 'Actions', alwaysVisible: true }, -] - -type ProcessedListingSortField = 'createdAt' | 'status' | 'game.title' +const HANDHELD_REPORT_ACCESSORS: ProcessedReportAccessors = { + getId: (listing) => listing.id, + getGameTitle: (listing) => listing.game.title, + getSystemName: (listing) => listing.game.system.name, + getSystemKey: (listing) => listing.game.system.key, + getEmulatorName: (listing) => listing.emulator.name, + getEmulatorLogo: (listing) => listing.emulator.logo, + getAuthor: (listing) => listing.author, + getProcessedByName: (listing) => listing.processedByUser?.name, + getProcessedAt: (listing) => listing.processedAt, + getProcessedNotes: (listing) => listing.processedNotes, + getStatus: (listing) => listing.status, + getEditHref: (listing) => `/admin/listings/${listing.id}/edit`, + getViewHref: (listing) => `/listings/${listing.id}`, +} function ProcessedListingsPage() { const table = useAdminTable({ defaultLimit: 20, - defaultSortField: 'createdAt', + defaultSortField: 'processedAt', defaultSortDirection: 'desc', }) - const columnVisibility = useColumnVisibility(PROCESSED_LISTINGS_COLUMNS, { - storageKey: storageKeys.columnVisibility.adminProcessedListings, - }) - const [filterStatus, setFilterStatus] = useState(null) - + const currentUserQuery = api.users.me.useQuery() const listingStatsQuery = api.listings.stats.useQuery() const processedListingsQuery = api.listings.getProcessed.useQuery({ page: table.page, limit: table.limit, - filterStatus: filterStatus ?? undefined, - search: table.debouncedSearch || undefined, + filterStatus: filterStatus ?? null, + search: table.debouncedSearch || null, + sortField: table.sortField ?? null, + sortDirection: table.sortDirection ?? null, }) - const processedListings = processedListingsQuery.data?.listings ?? [] - const paginationData = processedListingsQuery.data?.pagination - const userQuery = api.users.me.useQuery() - - const [showOverrideModal, setShowOverrideModal] = useState(false) - const [selectedListingForOverride, setSelectedListingForOverride] = - useState(null) - const [overrideNotes, setOverrideNotes] = useState('') - const [newStatusForOverride, setNewStatusForOverride] = useState(null) - const utils = api.useUtils() + const invalidateAdminListingViews = async () => { + await Promise.all([ + utils.listings.getProcessed.invalidate(), + utils.listings.getPending.invalidate(), + utils.listings.get.invalidate(), + utils.listings.stats.invalidate(), + ]) + } + const overrideMutation = api.listings.overrideApprovalStatus.useMutation({ onSuccess: async () => { - toast.success('Listing status overridden successfully!') - await utils.listings.getProcessed.invalidate() - await utils.listings.getPending.invalidate() - await utils.listings.get.invalidate() - closeOverrideModal() + toast.success('Handheld report status updated.') + await invalidateAdminListingViews() }, onError: (err) => { - console.error('Failed to override status:', err) - toast.error(`Failed to override status: ${getErrorMessage(err)}`) + logger.error('Failed to override handheld report status:', err) + toast.error(`Failed to override handheld report status: ${getErrorMessage(err)}`) }, }) - const openOverrideModal = (listing: ProcessedListing, targetStatus: ApprovalStatus) => { - setSelectedListingForOverride(listing) - setNewStatusForOverride(targetStatus) - setOverrideNotes(listing.processedNotes ?? '') - setShowOverrideModal(true) - } - - const closeOverrideModal = () => { - setShowOverrideModal(false) - setSelectedListingForOverride(null) - setOverrideNotes('') - setNewStatusForOverride(null) - } - - const handleOverrideSubmit = () => { - if (selectedListingForOverride && newStatusForOverride) { - overrideMutation.mutate({ - listingId: selectedListingForOverride.id, - newStatus: newStatusForOverride, - overrideNotes: overrideNotes ?? undefined, - } satisfies RouterInput['listings']['overrideApprovalStatus']) - } - } - - const handleFilterChange = (ev: ChangeEvent) => { - const value = ev.target.value as ApprovalStatus | 'all' - setFilterStatus(value === 'all' ? null : value) - table.setPage(1) - } + const resetToPendingMutation = api.listings.resetToPending.useMutation({ + onSuccess: async () => { + toast.success('Handheld report returned to pending review.') + await invalidateAdminListingViews() + }, + onError: (err) => { + logger.error('Failed to return handheld report to pending review:', err) + toast.error(`Failed to return handheld report to pending review: ${getErrorMessage(err)}`) + }, + }) - if (processedListingsQuery.error) { - return ( -
- Error loading processed listings: {processedListingsQuery.error.message} -
- ) - } + const processedListings = processedListingsQuery.data?.listings ?? [] return ( - + + title="Handheld Processed Reports" + description="Review approved and rejected handheld compatibility reports. SUPER_ADMINs can override these decisions." + reportLabel="Handheld Compatibility Report" + loadingText="Loading processed handheld reports..." + errorMessage={ + processedListingsQuery.error + ? `Error loading processed handheld reports: ${processedListingsQuery.error.message}` + : null } - > - - - - table={table} - searchPlaceholder="Search by game name, author, or notes..." - onClear={() => setFilterStatus(null)} - > - - - - - {processedListingsQuery.isPending ? ( - - ) : processedListings.length === 0 ? ( - - ) : ( - - - - {columnVisibility.isColumnVisible('game') && ( - - )} - {columnVisibility.isColumnVisible('author') && ( - - )} - {columnVisibility.isColumnVisible('status') && ( - - )} - {columnVisibility.isColumnVisible('processedBy') && ( - - )} - {columnVisibility.isColumnVisible('processedAt') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - - - {processedListings.map((listing) => ( - - {columnVisibility.isColumnVisible('game') && ( - - )} - {columnVisibility.isColumnVisible('author') && ( - - )} - {columnVisibility.isColumnVisible('status') && ( - - )} - {columnVisibility.isColumnVisible('processedBy') && ( - - )} - {columnVisibility.isColumnVisible('processedAt') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - ))} - -
- Game / System - - Author - - Status - - Processed By (Admin) - - Processed At - - Actions -
- { - analytics.contentDiscovery.externalLinkClicked({ - url: `/listings/${listing.id}`, - context: 'admin_processed_listings_view', - entityId: listing.id, - }) - }} - > - {listing.game.title} - - -
- {listing.game.system.name} -
-
- {listing.author?.name ?? 'N/A'} - - - {listing.status} - - - {listing.processedByUser?.name ?? 'N/A'} - - {listing.processedAt ? ( - - ) : ( - 'N/A' - )} - - {hasPermission(userQuery.data?.permissions, PERMISSIONS.EDIT_ANY_LISTING) && ( - - )} - {hasPermission(userQuery.data?.permissions, PERMISSIONS.APPROVE_LISTINGS) && ( - openOverrideModal(listing, ApprovalStatus.PENDING)} - /> - )} - {hasPermission(userQuery.data?.permissions, PERMISSIONS.APPROVE_LISTINGS) && - listing.status === ApprovalStatus.APPROVED && ( - openOverrideModal(listing, ApprovalStatus.REJECTED)} - /> - )} - {hasPermission(userQuery.data?.permissions, PERMISSIONS.APPROVE_LISTINGS) && - listing.status === ApprovalStatus.REJECTED && ( - openOverrideModal(listing, ApprovalStatus.APPROVED)} - /> - )} -
- )} -
- - {paginationData && paginationData.pages > 1 && ( - - )} - - -
+ searchPlaceholder="Search by game, system, device, author, emulator, or notes..." + storageKey={storageKeys.columnVisibility.adminProcessedListings} + analyticsContext="admin_processed_handheld_reports_view" + table={table} + reports={processedListings} + pagination={processedListingsQuery.data?.pagination} + stats={listingStatsQuery.data ?? {}} + isStatsLoading={listingStatsQuery.isPending} + isReportsLoading={processedListingsQuery.isPending} + currentUserPermissions={currentUserQuery.data?.permissions} + currentUserRole={currentUserQuery.data?.role} + filterStatus={filterStatus} + hardwareColumns={HANDHELD_HARDWARE_COLUMNS} + accessors={HANDHELD_REPORT_ACCESSORS} + onFilterStatusChange={setFilterStatus} + onRetry={() => { + void processedListingsQuery.refetch() + }} + onOverrideStatus={async (request) => { + if (request.newStatus === ApprovalStatus.PENDING) { + await resetToPendingMutation.mutateAsync({ + listingId: request.report.id, + } satisfies RouterInput['listings']['resetToPending']) + return + } + + await overrideMutation.mutateAsync({ + listingId: request.report.id, + newStatus: request.newStatus, + overrideNotes: request.overrideNotes, + } satisfies RouterInput['listings']['overrideApprovalStatus']) + }} + isOverridePending={overrideMutation.isPending || resetToPendingMutation.isPending} + /> ) } diff --git a/src/data/storageKeys.ts b/src/data/storageKeys.ts index cd980e2c7..34f2367cb 100644 --- a/src/data/storageKeys.ts +++ b/src/data/storageKeys.ts @@ -37,7 +37,8 @@ const storageKeys = { adminGames: `${PREFIX}admin_games_column_visibility`, adminListings: `${PREFIX}admin_listings_column_visibility`, adminPerformance: `${PREFIX}admin_performance_column_visibility`, - adminProcessedListings: `${PREFIX}admin_processed_listings_column_visibility`, + adminProcessedListings: `${PREFIX}admin_processed_reports_column_visibility`, + adminPcProcessedListings: `${PREFIX}admin_pc_processed_listings_column_visibility`, adminSoCs: `${PREFIX}admin_socs_column_visibility`, adminSystems: `${PREFIX}admin_systems_column_visibility`, adminTrustLogs: `${PREFIX}admin_trust_logs_column_visibility`, diff --git a/src/schemas/pcListing.ts b/src/schemas/pcListing.ts index 5fcdb9563..85bbcd29c 100644 --- a/src/schemas/pcListing.ts +++ b/src/schemas/pcListing.ts @@ -66,27 +66,32 @@ export const GetPendingPcListingsSchema = z export const DeletePcListingSchema = z.object({ id: z.string().uuid() }) -// TODO: Wire up a PC admin processed-listings page + router procedure for -// parity with handheld (`admin.getProcessed` + `src/app/admin/processed-listings/`). -// When doing so, extend this schema with `sortField` / `sortDirection` using the -// same shape as `GetProcessedSchema` in `./listing.ts`, and ideally share as much -// of the admin router logic as possible (the two codebases are drifting — fixes -// applied to handheld listings often miss their PC counterpart). Candidates for -// shared code: `buildProcessedOrderBy`, the search `where` builder, the -// approval-flow branches. See also: `src/server/api/utils/listingHelpers.ts` -// (handheld) vs `pcListingHelpers.ts` (PC) — these helpers already exist and -// should be the basis for a shared abstraction. export const GetProcessedPcSchema = z.object({ page: z.number().default(1), limit: z.number().default(10), - filterStatus: z.nativeEnum(ApprovalStatus).optional(), - search: z.string().optional(), + filterStatus: z.nativeEnum(ApprovalStatus).nullable().optional(), + search: z.string().nullable().optional(), + sortField: z + .enum([ + 'processedAt', + 'createdAt', + 'status', + 'game.title', + 'game.system.name', + 'cpu', + 'gpu', + 'emulator.name', + 'author.name', + ]) + .nullable() + .optional(), + sortDirection: z.enum(['asc', 'desc']).nullable().optional(), }) export const OverridePcApprovalStatusSchema = z.object({ pcListingId: z.string().uuid(), newStatus: z.nativeEnum(ApprovalStatus), // PENDING, APPROVED, or REJECTED - overrideNotes: z.string().optional(), + overrideNotes: z.string().nullable().optional(), }) export const ResetPcListingToPendingSchema = z.object({ diff --git a/src/server/api/routers/listings/admin.test.ts b/src/server/api/routers/listings/admin.test.ts index 147f16bf1..9fa39d2bf 100644 --- a/src/server/api/routers/listings/admin.test.ts +++ b/src/server/api/routers/listings/admin.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, beforeEach, vi } from 'vitest' import { RISK_SIGNAL_TYPES } from '@/schemas/authorRisk' import { SUBMISSION_RISK_SIGNAL_TYPES } from '@/schemas/submissionRisk' -import { ApprovalStatus, Role } from '@orm/client' +import { invalidateListingSeo } from '@/server/cache/invalidation' +import { notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { invalidateCatalogCompatibilityCacheForDevice } from '@/server/utils/cache/instances' +import { ApprovalStatus, Role, TrustAction } from '@orm' import type * as AuthorRiskService from '@/server/services/author-risk.service' vi.unmock('@/server/api/trpc') @@ -263,6 +266,237 @@ describe('listing admin pending approvals', () => { }) }) +describe('listing admin processed reports', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function setupPrisma() { + const listing = { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + } + const prismaMock = prisma as unknown as { + listing: typeof listing + } + + prismaMock.listing = listing + + return { listing } + } + + it('searches processed handheld reports across visible report columns', async () => { + const processedListing = { + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + } + const { listing } = setupPrisma() + listing.findMany.mockResolvedValueOnce([processedListing]) + listing.count.mockResolvedValueOnce(1) + + const { caller } = createCaller({ role: Role.SUPER_ADMIN }) + + const result = await caller.getProcessed({ + page: 1, + limit: 20, + filterStatus: ApprovalStatus.REJECTED, + search: 'ayaneo', + sortField: 'device', + sortDirection: 'asc', + }) + + expect(listing.findMany).toHaveBeenCalledWith({ + where: { + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.REJECTED, + OR: [ + { game: { title: { contains: 'ayaneo', mode: 'insensitive' } } }, + { game: { system: { name: { contains: 'ayaneo', mode: 'insensitive' } } } }, + { device: { modelName: { contains: 'ayaneo', mode: 'insensitive' } } }, + { device: { brand: { name: { contains: 'ayaneo', mode: 'insensitive' } } } }, + { emulator: { name: { contains: 'ayaneo', mode: 'insensitive' } } }, + { author: { name: { contains: 'ayaneo', mode: 'insensitive' } } }, + { processedNotes: { contains: 'ayaneo', mode: 'insensitive' } }, + { notes: { contains: 'ayaneo', mode: 'insensitive' } }, + ], + }, + include: { + game: { include: { system: true } }, + device: { include: { brand: true } }, + emulator: true, + author: { select: { id: true, name: true } }, + performance: true, + processedByUser: { select: { id: true, name: true } }, + }, + orderBy: [{ device: { brand: { name: 'asc' } } }, { device: { modelName: 'asc' } }], + skip: 0, + take: 20, + }) + expect(listing.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.REJECTED, + }), + }) + expect(result.listings).toEqual([processedListing]) + expect(result.pagination.total).toBe(1) + }) +}) + +describe('listing admin processed report status overrides', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function setupPrisma() { + const listing = { + findUnique: vi.fn(), + update: vi.fn(), + } + const prismaMock = prisma as unknown as { + listing: typeof listing + } + + prismaMock.listing = listing + + return { listing } + } + + it('emits a rejection notification when a processed handheld report is overridden to rejected', async () => { + const processedAt = new Date('2026-06-01T12:00:00.000Z') + const { listing } = setupPrisma() + listing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + authorId: AUTHOR_ID, + processedNotes: 'Old notes', + }) + listing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + processedAt, + }) + + const { caller } = createCaller({ role: Role.SUPER_ADMIN }) + + await caller.overrideStatus({ + listingId: LISTING_ID, + newStatus: ApprovalStatus.REJECTED, + overrideNotes: 'Incorrect report', + }) + + expect(listing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.REJECTED, + processedByUserId: ADMIN_ID, + processedAt: expect.any(Date), + processedNotes: 'Incorrect report', + }, + }) + expect(invalidateListingSeo).toHaveBeenCalledWith({ + id: LISTING_ID, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + }) + expect(invalidateCatalogCompatibilityCacheForDevice).toHaveBeenCalledWith( + '00000000-0000-4000-a000-000000000031', + ) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_REJECTED, + context: { + listingId: LISTING_ID, + adminUserId: ADMIN_ID, + reason: 'Incorrect report', + }, + }) + expect(notificationEventEmitter.emitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'LISTING_REJECTED', + entityType: 'listing', + entityId: LISTING_ID, + triggeredBy: ADMIN_ID, + payload: { + listingId: LISTING_ID, + rejectedBy: ADMIN_ID, + rejectedAt: processedAt, + rejectionReason: 'Incorrect report', + }, + }) + }) + + it('clears processed metadata without emitting a notification when overriding to pending', async () => { + const { listing } = setupPrisma() + listing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + authorId: AUTHOR_ID, + processedNotes: 'Rejected notes', + }) + listing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + const { caller } = createCaller({ role: Role.SUPER_ADMIN }) + + await caller.overrideStatus({ + listingId: LISTING_ID, + newStatus: ApprovalStatus.PENDING, + }) + + expect(listing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.PENDING, + processedByUserId: null, + processedAt: null, + processedNotes: null, + }, + }) + expect(invalidateListingSeo).not.toHaveBeenCalled() + expect(mockApplyTrustAction).not.toHaveBeenCalled() + expect(notificationEventEmitter.emitNotificationEvent).not.toHaveBeenCalled() + }) + + it('invalidates public handheld report caches when resetting an approved report to pending', async () => { + const { listing } = setupPrisma() + listing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + }) + listing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + const { caller } = createCaller({ role: Role.MODERATOR }) + + await caller.resetToPending({ listingId: LISTING_ID }) + + expect(invalidateListingSeo).toHaveBeenCalledWith({ + id: LISTING_ID, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + }) + expect(invalidateCatalogCompatibilityCacheForDevice).toHaveBeenCalledWith( + '00000000-0000-4000-a000-000000000031', + ) + expect(notificationEventEmitter.emitNotificationEvent).not.toHaveBeenCalled() + }) +}) + describe('listing admin auto risk rejection', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/server/api/routers/listings/admin.ts b/src/server/api/routers/listings/admin.ts index e2d03bfe7..5ff2e3d9a 100644 --- a/src/server/api/routers/listings/admin.ts +++ b/src/server/api/routers/listings/admin.ts @@ -28,6 +28,7 @@ import { protectedProcedure, } from '@/server/api/trpc' import { buildProcessedOrderBy } from '@/server/api/utils/listingHelpers' +import { getProcessedStatusTrustAction } from '@/server/api/utils/processedStatusTrust' import { invalidateListingSeo, invalidateListingsSeo } from '@/server/cache/invalidation' import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' import { ListingsRepository } from '@/server/repositories/listings.repository' @@ -47,7 +48,8 @@ import { import { generateEmulatorConfig } from '@/server/utils/emulator-config/emulator-detector' import { paginate } from '@/server/utils/pagination' import { hasRolePermission } from '@/utils/permissions' -import { Prisma, ApprovalStatus, TrustAction, Role } from '@orm/client' +import { ApprovalStatus, Role, TrustAction } from '@orm' +import { Prisma } from '@orm/client' const LISTING_STATS_CACHE_KEY = 'listing-stats' @@ -350,7 +352,7 @@ export const adminRouter = createTRPCRouter({ const listing = await ctx.prisma.listing.findUnique({ where: { id: listingId }, - select: { id: true, status: true }, + select: { id: true, status: true, gameId: true, deviceId: true, emulatorId: true }, }) if (!listing) return ResourceError.listing.notFound() @@ -371,6 +373,16 @@ export const adminRouter = createTRPCRouter({ listingStatsCache.delete(LISTING_STATS_CACHE_KEY) + if (listing.status === ApprovalStatus.APPROVED) { + await invalidateListingSeo({ + id: listingId, + gameId: listing.gameId, + deviceId: listing.deviceId, + emulatorId: listing.emulatorId, + }) + invalidateCatalogCompatibilityCacheForDevice(listing.deviceId) + } + return updatedListing }), @@ -387,6 +399,10 @@ export const adminRouter = createTRPCRouter({ ? { OR: [ { game: { title: { contains: search, mode } } }, + { game: { system: { name: { contains: search, mode } } } }, + { device: { modelName: { contains: search, mode } } }, + { device: { brand: { name: { contains: search, mode } } } }, + { emulator: { name: { contains: search, mode } } }, { author: { name: { contains: search, mode } } }, { processedNotes: { contains: search, mode } }, { notes: { contains: search, mode } }, @@ -434,34 +450,91 @@ export const adminRouter = createTRPCRouter({ const listingToOverride = await ctx.prisma.listing.findUnique({ where: { id: listingId }, + select: { + id: true, + status: true, + gameId: true, + deviceId: true, + emulatorId: true, + authorId: true, + processedNotes: true, + }, }) if (!listingToOverride) return ResourceError.listing.notFound() const updatedListing = await ctx.prisma.listing.update({ where: { id: listingId }, - data: { - status: newStatus, - processedByUserId: superAdminUserId, // Log the SUPER_ADMIN as the latest processor - processedAt: new Date(), // Update timestamp to the override time - processedNotes: overrideNotes ?? listingToOverride.processedNotes, // Keep old notes if no new ones - }, + data: + newStatus === ApprovalStatus.PENDING + ? { + status: newStatus, + processedByUserId: null, + processedAt: null, + processedNotes: null, + } + : { + status: newStatus, + processedByUserId: superAdminUserId, + processedAt: new Date(), + processedNotes: overrideNotes ?? listingToOverride.processedNotes, + }, }) - // Emit notification event - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.LISTING_STATUS_OVERRIDDEN, - entityType: 'listing', - entityId: listingId, - triggeredBy: superAdminUserId, - payload: { - listingId, - overriddenBy: superAdminUserId, - newStatus, - overriddenAt: updatedListing.processedAt, - overrideNotes: overrideNotes, - }, + if ( + listingToOverride.status === ApprovalStatus.APPROVED || + newStatus === ApprovalStatus.APPROVED + ) { + await invalidateListingSeo({ + id: listingId, + gameId: listingToOverride.gameId, + deviceId: listingToOverride.deviceId, + emulatorId: listingToOverride.emulatorId, + }) + invalidateCatalogCompatibilityCacheForDevice(listingToOverride.deviceId) + } + + const trustAction = getProcessedStatusTrustAction({ + previousStatus: listingToOverride.status, + newStatus, + authorId: listingToOverride.authorId, }) + if (trustAction) { + await applyTrustAction({ + userId: trustAction.userId, + action: trustAction.action, + context: { + listingId, + adminUserId: superAdminUserId, + reason: overrideNotes || 'listing_status_override', + }, + }) + } + + if (newStatus === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.REJECTED) { + notificationEventEmitter.emitNotificationEvent({ + eventType: + newStatus === ApprovalStatus.APPROVED + ? NOTIFICATION_EVENTS.LISTING_APPROVED + : NOTIFICATION_EVENTS.LISTING_REJECTED, + entityType: 'listing', + entityId: listingId, + triggeredBy: superAdminUserId, + payload: + newStatus === ApprovalStatus.APPROVED + ? { + listingId, + approvedBy: superAdminUserId, + approvedAt: updatedListing.processedAt, + } + : { + listingId, + rejectedBy: superAdminUserId, + rejectedAt: updatedListing.processedAt, + rejectionReason: overrideNotes, + }, + }) + } // Invalidate listing stats cache listingStatsCache.delete(LISTING_STATS_CACHE_KEY) diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index d60abece5..3144e2e6b 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -7,7 +7,7 @@ import { invalidatePcListingsSeo, } from '@/server/cache/invalidation' import { PERMISSIONS } from '@/utils/permission-system' -import { ApprovalStatus, PcOs, ReportReason, Role, TrustAction } from '@orm/client' +import { ApprovalStatus, PcOs, ReportReason, Role, TrustAction } from '@orm' vi.unmock('@/server/api/trpc') vi.unmock('@/server/api/root') @@ -195,6 +195,7 @@ function createMockPrisma() { pcListing: { findUnique: vi.fn(), findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 0 }), }, @@ -853,6 +854,159 @@ describe('pcListings trust integration', () => { }) }) + describe('getProcessed', () => { + it('loads processed PC reports with status, search, pagination, and sorting', async () => { + const processedListing = { + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + } + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.SUPER_ADMIN }) + prisma.pcListing.findMany.mockResolvedValueOnce([processedListing]) + prisma.pcListing.count.mockResolvedValueOnce(1) + + const result = await caller.getProcessed({ + page: 2, + limit: 10, + filterStatus: ApprovalStatus.APPROVED, + search: 'steam deck', + sortField: 'cpu', + sortDirection: 'asc', + }) + + expect(prisma.pcListing.findMany).toHaveBeenCalledWith({ + where: { + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.APPROVED, + OR: [ + { game: { title: { contains: 'steam deck', mode: 'insensitive' } } }, + { game: { system: { name: { contains: 'steam deck', mode: 'insensitive' } } } }, + { cpu: { modelName: { contains: 'steam deck', mode: 'insensitive' } } }, + { cpu: { brand: { name: { contains: 'steam deck', mode: 'insensitive' } } } }, + { gpu: { modelName: { contains: 'steam deck', mode: 'insensitive' } } }, + { gpu: { brand: { name: { contains: 'steam deck', mode: 'insensitive' } } } }, + { emulator: { name: { contains: 'steam deck', mode: 'insensitive' } } }, + { author: { name: { contains: 'steam deck', mode: 'insensitive' } } }, + { processedNotes: { contains: 'steam deck', mode: 'insensitive' } }, + { notes: { contains: 'steam deck', mode: 'insensitive' } }, + ], + }, + include: expect.objectContaining({ processedByUser: true }), + orderBy: [{ cpu: { brand: { name: 'asc' } } }, { cpu: { modelName: 'asc' } }], + skip: 10, + take: 10, + }) + expect(prisma.pcListing.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.APPROVED, + }), + }) + expect(result.pcListings).toEqual([processedListing]) + expect(result.pagination.total).toBe(1) + }) + }) + + describe('overrideStatus', () => { + it('updates a processed PC report, invalidates SEO, and emits a rejected event', async () => { + const gameId = '00000000-0000-4000-a000-000000000040' + const cpuId = '00000000-0000-4000-a000-000000000070' + const processedAt = new Date('2026-06-01T12:00:00.000Z') + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.SUPER_ADMIN }) + prisma.pcListing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + gameId, + cpuId, + gpuId: null, + authorId: AUTHOR_ID, + processedNotes: 'Old notes', + }) + prisma.pcListing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + processedAt, + }) + + await caller.overrideStatus({ + pcListingId: LISTING_ID, + newStatus: ApprovalStatus.REJECTED, + overrideNotes: 'Incorrect hardware', + }) + + expect(prisma.pcListing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.REJECTED, + processedByUserId: ADMIN_ID, + processedAt: expect.any(Date), + processedNotes: 'Incorrect hardware', + }, + }) + expect(invalidatePcListingSeo).toHaveBeenCalledWith({ + id: LISTING_ID, + gameId, + cpuId, + gpuId: null, + }) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: LISTING_ID, + adminUserId: ADMIN_ID, + reason: 'Incorrect hardware', + }, + }) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'PC_LISTING_REJECTED', + entityType: 'pcListing', + entityId: LISTING_ID, + triggeredBy: ADMIN_ID, + payload: { + pcListingId: LISTING_ID, + rejectedBy: ADMIN_ID, + rejectedAt: processedAt, + rejectionReason: 'Incorrect hardware', + }, + }) + }) + + it('clears processed metadata without emitting a notification when returning to pending', async () => { + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.SUPER_ADMIN }) + prisma.pcListing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + gameId: '00000000-0000-4000-a000-000000000040', + cpuId: '00000000-0000-4000-a000-000000000070', + gpuId: null, + authorId: AUTHOR_ID, + processedNotes: 'Rejected notes', + }) + prisma.pcListing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + await caller.overrideStatus({ + pcListingId: LISTING_ID, + newStatus: ApprovalStatus.PENDING, + }) + + expect(prisma.pcListing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.PENDING, + processedByUserId: null, + processedAt: null, + processedNotes: null, + }, + }) + expect(invalidatePcListingSeo).not.toHaveBeenCalled() + expect(mockApplyTrustAction).not.toHaveBeenCalled() + expect(mockEmitNotificationEvent).not.toHaveBeenCalled() + }) + }) + describe('approve', () => { it('calls applyTrustAction with LISTING_APPROVED for author', async () => { mockRepositoryGetById.mockResolvedValue({ diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index b82a3bfb1..7e4fb11ac 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -24,6 +24,8 @@ import { GetPcListingVerificationsSchema, GetPcPresetsSchema, GetPendingPcListingsSchema, + GetProcessedPcSchema, + OverridePcApprovalStatusSchema, PinPcListingCommentSchema, RejectPcListingSchema, RemovePcListingVerificationSchema, @@ -45,16 +47,19 @@ import { permissionProcedure, protectedProcedure, publicProcedure, + superAdminProcedure, viewStatisticsProcedure, } from '@/server/api/trpc' import { buildCommentTree, findCommentWithParent } from '@/server/api/utils/commentTree' import { buildPcListingOrderBy, buildPcListingWhere, + buildProcessedPcListingOrderBy, pcListingAdminInclude, pcListingDetailInclude, } from '@/server/api/utils/pcListingHelpers' import { canManageCommentPins } from '@/server/api/utils/pinPermissions' +import { getProcessedStatusTrustAction } from '@/server/api/utils/processedStatusTrust' import { invalidatePcListingSeo, invalidatePcListingSeoForUpdate, @@ -91,15 +96,8 @@ import { hasRolePermission, isModerator, } from '@/utils/permissions' -import { - ApprovalStatus, - AuditAction, - AuditEntityType, - Prisma, - ReportStatus, - Role, - TrustAction, -} from '@orm/client' +import { ApprovalStatus, AuditAction, AuditEntityType, ReportStatus, Role, TrustAction } from '@orm' +import { Prisma } from '@orm/client' function isJsonRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -640,6 +638,8 @@ export const pcListingsRouter = createTRPCRouter({ payload: { pcListingId: input.pcListingId, gameId: pcListing.gameId, + approvedBy: ctx.session.user.id, + approvedAt: approvedListing.processedAt, }, }) @@ -750,6 +750,148 @@ export const pcListingsRouter = createTRPCRouter({ return updatedListing }), + getProcessed: superAdminProcedure.input(GetProcessedPcSchema).query(async ({ ctx, input }) => { + const { page, limit, filterStatus, search, sortField, sortDirection } = input + const skip = (page - 1) * limit + + const baseWhere: Prisma.PcListingWhereInput = { + NOT: { status: ApprovalStatus.PENDING }, + ...(filterStatus ? { status: filterStatus } : {}), + } + + const searchWhere: Prisma.PcListingWhereInput = search + ? { + OR: [ + { game: { title: { contains: search, mode: 'insensitive' } } }, + { game: { system: { name: { contains: search, mode: 'insensitive' } } } }, + { cpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { cpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, + { gpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { gpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, + { emulator: { name: { contains: search, mode: 'insensitive' } } }, + { author: { name: { contains: search, mode: 'insensitive' } } }, + { processedNotes: { contains: search, mode: 'insensitive' } }, + { notes: { contains: search, mode: 'insensitive' } }, + ], + } + : {} + + const where = buildPcListingWhere({ ...baseWhere, ...searchWhere }, true) + const orderBy = buildProcessedPcListingOrderBy(sortField, sortDirection) + + const [pcListings, total] = await Promise.all([ + ctx.prisma.pcListing.findMany({ + where, + include: pcListingAdminInclude, + orderBy, + skip, + take: limit, + }), + ctx.prisma.pcListing.count({ where }), + ]) + + return { + pcListings, + pagination: paginate({ total, page, limit }), + } + }), + + overrideStatus: superAdminProcedure + .input(OverridePcApprovalStatusSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId, newStatus, overrideNotes } = input + const superAdminUserId = ctx.session.user.id + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + select: { + id: true, + status: true, + gameId: true, + cpuId: true, + gpuId: true, + authorId: true, + processedNotes: true, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id: pcListingId }, + data: + newStatus === ApprovalStatus.PENDING + ? { + status: newStatus, + processedByUserId: null, + processedAt: null, + processedNotes: null, + } + : { + status: newStatus, + processedByUserId: superAdminUserId, + processedAt: new Date(), + processedNotes: overrideNotes ?? pcListing.processedNotes, + }, + }) + + listingStatsCache.delete('pc-listing-stats') + + if (pcListing.status === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo({ + id: pcListingId, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }) + } + + const trustAction = getProcessedStatusTrustAction({ + previousStatus: pcListing.status, + newStatus, + authorId: pcListing.authorId, + }) + if (trustAction) { + await applyTrustAction({ + userId: trustAction.userId, + action: trustAction.action, + context: { + pcListingId, + adminUserId: superAdminUserId, + reason: overrideNotes || 'pc_listing_status_override', + }, + }) + } + + if (newStatus === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.REJECTED) { + notificationEventEmitter.emitNotificationEvent({ + eventType: + newStatus === ApprovalStatus.APPROVED + ? NOTIFICATION_EVENTS.PC_LISTING_APPROVED + : NOTIFICATION_EVENTS.PC_LISTING_REJECTED, + entityType: 'pcListing', + entityId: pcListingId, + triggeredBy: superAdminUserId, + payload: + newStatus === ApprovalStatus.APPROVED + ? { + pcListingId, + gameId: pcListing.gameId, + approvedBy: superAdminUserId, + approvedAt: updatedPcListing.processedAt, + } + : { + pcListingId, + rejectedBy: superAdminUserId, + rejectedAt: updatedPcListing.processedAt, + rejectionReason: overrideNotes, + }, + }) + } + + return updatedPcListing + }), + bulkApprove: protectedProcedure .input(BulkApprovePcListingsSchema) .mutation(async ({ ctx, input }) => { @@ -766,17 +908,17 @@ export const pcListingsRouter = createTRPCRouter({ where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, select: { id: true, gameId: true, cpuId: true, gpuId: true, authorId: true }, }) + const approvedAt = new Date() const result = await ctx.prisma.pcListing.updateMany({ where: { id: { in: pendingListings.map((l) => l.id) } }, data: { status: ApprovalStatus.APPROVED, - processedAt: new Date(), + processedAt: approvedAt, processedByUserId: ctx.session.user.id, }, }) - // Apply trust actions in parallel — distinct user adjustments, independent. const listingsWithAuthor = pendingListings.filter( (l): l is typeof l & { authorId: string } => l.authorId !== null, ) @@ -807,6 +949,9 @@ export const pcListingsRouter = createTRPCRouter({ payload: { pcListingId: listing.id, gameId: listing.gameId, + approvedBy: ctx.session.user.id, + approvedAt, + bulk: true, }, }) } diff --git a/src/server/api/utils/pcListingHelpers.ts b/src/server/api/utils/pcListingHelpers.ts index 7b4a85d8f..85c3bd73f 100644 --- a/src/server/api/utils/pcListingHelpers.ts +++ b/src/server/api/utils/pcListingHelpers.ts @@ -95,6 +95,47 @@ export function buildPcListingOrderBy( return orderBy } +export type ProcessedPcListingSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'cpu' + | 'gpu' + | 'emulator.name' + | 'author.name' + +export function buildProcessedPcListingOrderBy( + sortField: ProcessedPcListingSortField | null | undefined, + sortDirection: 'asc' | 'desc' | null | undefined, +): Prisma.PcListingOrderByWithRelationInput | Prisma.PcListingOrderByWithRelationInput[] { + const direction: Prisma.SortOrder = sortDirection ?? 'desc' + + switch (sortField) { + case 'createdAt': + return { createdAt: direction } + case 'status': + return { status: direction } + case 'game.title': + return { game: { title: direction } } + case 'game.system.name': + return { game: { system: { name: direction } } } + case 'cpu': + return [{ cpu: { brand: { name: direction } } }, { cpu: { modelName: direction } }] + case 'gpu': + return [{ gpu: { brand: { name: direction } } }, { gpu: { modelName: direction } }] + case 'emulator.name': + return { emulator: { name: direction } } + case 'author.name': + return { author: { name: direction } } + case 'processedAt': + case null: + case undefined: + return { processedAt: direction } + } +} + /** * Builds where clause for PC listings with banned user filtering */ diff --git a/src/server/api/utils/processedStatusTrust.ts b/src/server/api/utils/processedStatusTrust.ts new file mode 100644 index 000000000..2aef3bf82 --- /dev/null +++ b/src/server/api/utils/processedStatusTrust.ts @@ -0,0 +1,27 @@ +import { ApprovalStatus, TrustAction } from '@orm' + +interface ProcessedStatusTrustInput { + previousStatus: ApprovalStatus + newStatus: ApprovalStatus + authorId?: string | null +} + +interface ProcessedStatusTrustAction { + userId: string + action: TrustAction +} + +export function getProcessedStatusTrustAction( + input: ProcessedStatusTrustInput, +): ProcessedStatusTrustAction | null { + if (!input.authorId || input.previousStatus === input.newStatus) return null + + switch (input.newStatus) { + case ApprovalStatus.APPROVED: + return { userId: input.authorId, action: TrustAction.LISTING_APPROVED } + case ApprovalStatus.REJECTED: + return { userId: input.authorId, action: TrustAction.LISTING_REJECTED } + case ApprovalStatus.PENDING: + return null + } +} diff --git a/src/server/notifications/eventEmitter.ts b/src/server/notifications/eventEmitter.ts index 3398fe035..6aeb347a7 100644 --- a/src/server/notifications/eventEmitter.ts +++ b/src/server/notifications/eventEmitter.ts @@ -53,7 +53,6 @@ export const NOTIFICATION_EVENTS = { USER_MENTIONED: 'user.mentioned', LISTING_APPROVED: 'listing.approved', LISTING_REJECTED: 'listing.rejected', - LISTING_STATUS_OVERRIDDEN: 'listing.status_overridden', LISTING_VERIFIED: 'listing.verified', CONTENT_FLAGGED: 'content.flagged', GAME_ADDED: 'game.added', diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts index 3085c1156..90c26a699 100644 --- a/src/server/notifications/service.ts +++ b/src/server/notifications/service.ts @@ -17,10 +17,7 @@ import { Role, } from '@orm/client' import { createEmailService } from './emailService' -import { - type NotificationEventData, - notificationEventEmitter, -} from './eventEmitter' +import { type NotificationEventData, notificationEventEmitter } from './eventEmitter' import { notificationRateLimitService } from './rateLimitService' import { notificationTemplateEngine, type TemplateContext } from './templates' import type { @@ -252,7 +249,6 @@ export class NotificationService { 'pcListing.approved': NotificationType.LISTING_APPROVED, 'listing.rejected': NotificationType.LISTING_REJECTED, 'pcListing.rejected': NotificationType.LISTING_REJECTED, - 'listing.status_overridden': NotificationType.LISTING_APPROVED, 'content.flagged': NotificationType.CONTENT_FLAGGED, 'game.added': NotificationType.GAME_ADDED, 'emulator.updated': NotificationType.EMULATOR_UPDATED,