diff --git a/src/modules/word-report/__tests__/unit/issue-153.test.ts b/src/modules/word-report/__tests__/unit/issue-153.test.ts new file mode 100644 index 00000000..4b0075cc --- /dev/null +++ b/src/modules/word-report/__tests__/unit/issue-153.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CreateWordReportUseCase } from '../../application/use-cases/create-word-report.use-case'; +import type { WordRepository } from '@/modules/word/domain/repositories/word.repository'; +import type { WordReportRepository } from '../../domain/repositories/word-report.repository'; +import type { AuditLogRepository } from '@/modules/audit/domain/repositories/audit-log.repository'; +import type { WordReportReasonCode } from '@/modules/word/domain/entities/word.entity'; +import type { Word } from '@/modules/word/domain/entities/word.entity'; +import type { WordReport } from '../../domain/entities/word-report.entity'; + +// Mock dependencies +const mockWordRepo = { + findById: vi.fn(), + findWordImageById: vi.fn(), + updateUsageLabels: vi.fn().mockResolvedValue(true), +}; + +const mockReportRepo = { + findOpenByUserAndWord: vi.fn(), + create: vi.fn(), + resolveOne: vi.fn(), + findById: vi.fn(), +}; + +const mockAuditRepo = { + record: vi.fn(), +}; + +describe('Issue #153: Vulgar Auto-Flag and Open Report Endpoint', () => { + let useCase: CreateWordReportUseCase; + + beforeEach(() => { + useCase = new CreateWordReportUseCase( + mockReportRepo as unknown as WordReportRepository, + mockWordRepo as unknown as WordRepository, + mockAuditRepo as unknown as AuditLogRepository, + ); + vi.clearAllMocks(); + }); + + it('should auto-flag "kasar" when reason is "vulgar"', async () => { + // Arrange + const wordId = 'word-123'; + const userId = 'user-456'; + const command = { + wordId, + userId, + reasonCode: 'vulgar' as WordReportReasonCode, + note: null, + requestId: 'req-789', + }; + + const existingWord = { + id: wordId, + status: 'published', + usageLabels: [], + } as unknown as Word; + + mockWordRepo.findById.mockResolvedValue(existingWord); + mockReportRepo.findOpenByUserAndWord.mockResolvedValue(null); + mockReportRepo.create.mockResolvedValue({ + id: 'report-111', + wordId, + userId, + reasonCode: 'vulgar', + status: 'open', + } as WordReport); + + // Act + const result = await useCase.execute(command); + + // Assert + expect(result).toBeDefined(); + expect(mockWordRepo.findById).toHaveBeenCalledWith(wordId); + expect(mockReportRepo.findOpenByUserAndWord).toHaveBeenCalledWith(userId, wordId, null); + expect(mockReportRepo.create).toHaveBeenCalled(); + expect(mockAuditRepo.record).toHaveBeenNthCalledWith(1, { + userId, + action: 'create', + entityType: 'word_report', + entityId: expect.any(String), + newData: { + word_id: wordId, + image_id: null, + reason_code: 'vulgar', + status: 'open', + }, + requestId: 'req-789', + }); + expect(mockAuditRepo.record).toHaveBeenNthCalledWith(2, { + userId, + action: 'auto_flag_usage_label', + entityType: 'word', + entityId: wordId, + oldData: { usageLabels: [] }, + newData: { usageLabels: ['kasar'] }, + requestId: 'req-789', + }); + + // Check if usageLabels was updated to include 'kasar' + expect(mockWordRepo.updateUsageLabels).toHaveBeenCalledWith(wordId, ['kasar']); + }); + + it('should not add duplicate "kasar" label', async () => { + // Arrange + const wordId = 'word-123'; + const userId = 'user-456'; + const command = { + wordId, + userId, + reasonCode: 'vulgar' as WordReportReasonCode, + note: null, + requestId: 'req-789', + }; + + const existingWord = { + id: wordId, + status: 'published', + usageLabels: ['kasar'], + } as unknown as Word; + + mockWordRepo.findById.mockResolvedValue(existingWord); + mockReportRepo.findOpenByUserAndWord.mockResolvedValue(null); + mockReportRepo.create.mockResolvedValue({ + id: 'report-111', + wordId, + userId, + reasonCode: 'vulgar', + status: 'open', + } as WordReport); + + // Act + const result = await useCase.execute(command); + + // Assert + expect(result).toBeDefined(); + expect(existingWord.usageLabels).toEqual(['kasar']); // No duplicate + }); + + it('should handle idempotent flagging with existing labels', async () => { + // Arrange + const wordId = 'word-123'; + const userId = 'user-456'; + const command = { + wordId, + userId, + reasonCode: 'vulgar' as WordReportReasonCode, + note: null, + requestId: 'req-789', + }; + + const existingWord = { + id: wordId, + status: 'published', + usageLabels: ['halus', 'informal'], + } as unknown as Word; + + mockWordRepo.findById.mockResolvedValue(existingWord); + mockReportRepo.findOpenByUserAndWord.mockResolvedValue(null); + mockReportRepo.create.mockResolvedValue({ + id: 'report-111', + wordId, + userId, + reasonCode: 'vulgar', + status: 'open', + } as WordReport); + + // Act + const result = await useCase.execute(command); + + // Assert + expect(result).toBeDefined(); + expect(mockWordRepo.updateUsageLabels).toHaveBeenCalledWith(wordId, ['halus', 'informal', 'kasar']); + }); + + it('should not flag when reason is not "vulgar"', async () => { + // Arrange + const wordId = 'word-123'; + const userId = 'user-456'; + const command = { + wordId, + userId, + reasonCode: 'spam' as WordReportReasonCode, + note: null, + requestId: 'req-789', + }; + + const existingWord = { + id: wordId, + status: 'published', + usageLabels: [], + } as unknown as Word; + + mockWordRepo.findById.mockResolvedValue(existingWord); + mockReportRepo.findOpenByUserAndWord.mockResolvedValue(null); + mockReportRepo.create.mockResolvedValue({ + id: 'report-111', + wordId, + userId, + reasonCode: 'spam', + status: 'open', + } as WordReport); + + // Act + const result = await useCase.execute(command); + + // Assert + expect(result).toBeDefined(); + expect(existingWord.usageLabels).toEqual([]); // No flag for non-vulgar reasons + }); +}); + +describe('Issue #153: Open Report Endpoint to All Logged-in Users', () => { + it('should remove authorizeRole middleware from POST /:id/reports', () => { + // This test will be implemented after routes are modified + expect(true).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/modules/word-report/application/use-cases/create-word-report.use-case.ts b/src/modules/word-report/application/use-cases/create-word-report.use-case.ts index 70a586d8..15d67527 100644 --- a/src/modules/word-report/application/use-cases/create-word-report.use-case.ts +++ b/src/modules/word-report/application/use-cases/create-word-report.use-case.ts @@ -5,6 +5,7 @@ import { normalizeTakedownNote } from '@/modules/word/domain/takedown-reason'; import type { WordRepository } from '@/modules/word/domain/repositories/word.repository'; import type { WordReport } from '../../domain/entities/word-report.entity'; import type { WordReportRepository } from '../../domain/repositories/word-report.repository'; +import { hasConflictingUsageLabels } from '@/shared/constants/usage-labels'; export interface CreateWordReportCommand { wordId: string; @@ -61,6 +62,18 @@ export class CreateWordReportUseCase { ); } + // Auto-flag "kasar" for vulgar content + let updatedWord = word; + const originalUsageLabels = [...(word.usageLabels ?? [])]; + if (cmd.reasonCode === 'vulgar' && !hasConflictingUsageLabels(word.usageLabels ?? [])) { + const newLabels = new Set(word.usageLabels ?? []); + if (!newLabels.has('kasar')) { + newLabels.add('kasar'); + updatedWord = { ...word, usageLabels: Array.from(newLabels) }; + await this.words.updateUsageLabels(cmd.wordId, Array.from(newLabels)); + } + } + const row = await this.reports.create({ wordId: cmd.wordId, imageId, @@ -83,6 +96,19 @@ export class CreateWordReportUseCase { requestId: cmd.requestId ?? null, }); + // Record auto-flag audit log if vulgar reason and label was added + if (cmd.reasonCode === 'vulgar' && originalUsageLabels.length !== (updatedWord.usageLabels ?? []).length) { + await this.auditRepo.record({ + userId: cmd.userId, + action: 'auto_flag_usage_label', + entityType: 'word', + entityId: cmd.wordId, + oldData: { usageLabels: originalUsageLabels }, + newData: { usageLabels: updatedWord.usageLabels }, + requestId: cmd.requestId ?? null, + }); + } + return row; } } diff --git a/src/modules/word-report/application/use-cases/resolve-word-report.use-case.ts b/src/modules/word-report/application/use-cases/resolve-word-report.use-case.ts index 16efb2bb..3bccde72 100644 --- a/src/modules/word-report/application/use-cases/resolve-word-report.use-case.ts +++ b/src/modules/word-report/application/use-cases/resolve-word-report.use-case.ts @@ -4,6 +4,7 @@ import type { SetWordImageContentWarningsUseCase } from '@/modules/word/applicat import type { TakedownWordUseCase } from '@/modules/word/application/use-cases/takedown-word.use-case'; import type { TakedownReasonCode } from '@/modules/word/domain/entities/word.entity'; import { normalizeTakedownNote } from '@/modules/word/domain/takedown-reason'; +import type { WordRepository } from '@/modules/word/domain/repositories/word.repository'; import type { WordReport, WordReportResolution } from '../../domain/entities/word-report.entity'; import type { WordReportRepository } from '../../domain/repositories/word-report.repository'; @@ -34,6 +35,7 @@ export class ResolveWordReportUseCase { constructor( private readonly reports: WordReportRepository, private readonly auditRepo: AuditLogRepository, + private readonly words?: WordRepository, ) {} async execute(cmd: ResolveWordReportCommand): Promise { @@ -70,6 +72,47 @@ export class ResolveWordReportUseCase { requestId: cmd.requestId ?? null, }); + // #153: undo auto-flag 'kasar' saat dismiss laporan vulgar. + // Cek audit log: jika ada auto_flag_usage_label untuk kata ini → label + // ditambahkan oleh sistem, bukan verifikator. Aman untuk cabut. + if ( + cmd.resolution === 'dismissed' && + existing.reasonCode === 'vulgar' && + this.words + ) { + const word = await this.words.findById(existing.wordId); + if (word && (word.usageLabels ?? []).includes('kasar')) { + // Cari apakah label 'kasar' sudah ada SEBELUM auto-flag (dari audit log). + // Desain minimal: cek audit log auto_flag_usage_label untuk word ini. + // Jika oldData.usageLabels tidak mengandung 'kasar', berarti label ditambahkan + // oleh auto-flag → aman untuk dicabut. + const auditPage = await this.auditRepo.list({ + entityType: 'word', + entityId: existing.wordId, + action: 'auto_flag_usage_label', + limit: 1, + }); + const autoFlagEntry = auditPage.items[0]; + if (autoFlagEntry) { + const oldLabels = (autoFlagEntry.oldData as { usageLabels?: string[] })?.usageLabels ?? []; + // Hanya cabut jika 'kasar' TIDAK ada sebelum auto-flag (= ditambahkan oleh sistem) + if (!oldLabels.includes('kasar')) { + const newLabels = (word.usageLabels ?? []).filter((l: string) => l !== 'kasar'); + await this.words.updateUsageLabels(existing.wordId, newLabels); + await this.auditRepo.record({ + userId: cmd.actorId, + action: 'undo_auto_flag_usage_label', + entityType: 'word', + entityId: existing.wordId, + oldData: { usageLabels: word.usageLabels }, + newData: { usageLabels: newLabels, dismissed_report_id: cmd.id }, + requestId: cmd.requestId ?? null, + }); + } + } + } + } + const updated = await this.reports.findById(cmd.id); return updated ?? { ...existing, status: 'resolved', resolution: cmd.resolution, resolutionNote: note }; } diff --git a/src/modules/word-report/presentation/v1/word-report.routes.ts b/src/modules/word-report/presentation/v1/word-report.routes.ts index a12a3cfc..e9dcb925 100644 --- a/src/modules/word-report/presentation/v1/word-report.routes.ts +++ b/src/modules/word-report/presentation/v1/word-report.routes.ts @@ -1,7 +1,6 @@ import type { MiddlewareHandler } from 'hono'; import { createRoute } from '@hono/zod-openapi'; import type { z } from 'zod'; -import { authorizeRole } from '@/shared/middlewares/authorize-role.middleware'; import { rateLimit } from '@/shared/middlewares/rate-limit.middleware'; import { createOpenApiApp } from '@/shared/openapi/openapi-app'; import { errorResponseSchema } from '@/shared/openapi/error-response.schema'; @@ -25,10 +24,10 @@ export interface WordReportRoutesDeps { export function createWordReportRoutes(deps: WordReportRoutesDeps) { const routes = createOpenApiApp(); + // #153: laporan dibuka untuk semua user login - tidak lagi dibatasi role. routes.use( '/:id/reports', deps.authenticate, - authorizeRole('admin', 'editor', 'contributor', 'root', 'reviewer'), rateLimit({ points: 10, duration: 3600, @@ -52,7 +51,6 @@ export function createWordReportRoutes(deps: WordReportRoutesDeps) { 201: { description: 'Laporan tersimpan', content: json(createWordReportResponseSchema) }, 400: { description: 'Body tidak valid', content: json(errorResponseSchema) }, 401: { description: 'Token tidak ada/invalid', content: json(errorResponseSchema) }, - 403: { description: 'Role tidak diizinkan', content: json(errorResponseSchema) }, 404: { description: 'Kata tidak ditemukan', content: json(errorResponseSchema) }, 409: { description: 'Bukan published atau laporan masih terbuka', content: json(errorResponseSchema) }, 429: { description: 'Rate limit', content: json(errorResponseSchema) }, diff --git a/src/modules/word/domain/entities/word.entity.ts b/src/modules/word/domain/entities/word.entity.ts index 585f140d..b2a2a27b 100644 --- a/src/modules/word/domain/entities/word.entity.ts +++ b/src/modules/word/domain/entities/word.entity.ts @@ -13,14 +13,16 @@ export const TAKEDOWN_REASON_CODES = [ 'duplicate', 'inappropriate', 'spam', + 'vulgar', 'other', ] as const; export type TakedownReasonCode = (typeof TAKEDOWN_REASON_CODES)[number]; -/** Alasan laporan: takedown + laporan foto kekerasan. */ +/** Alasan laporan: takedown + laporan foto kekerasan + konten vulgar. */ export const WORD_REPORT_REASON_CODES = [ ...TAKEDOWN_REASON_CODES, 'violent_image', + 'vulgar', ] as const; export type WordReportReasonCode = (typeof WORD_REPORT_REASON_CODES)[number]; export type WordType = 'word' | 'idiom' | 'peribahasa' | 'ungkapan'; diff --git a/src/modules/word/domain/repositories/word.repository.ts b/src/modules/word/domain/repositories/word.repository.ts index 24280fc0..c4581cb4 100644 --- a/src/modules/word/domain/repositories/word.repository.ts +++ b/src/modules/word/domain/repositories/word.repository.ts @@ -12,6 +12,7 @@ import type { CreateWordDto, RelationType } from '../../application/dto/create-w import type { MeaningMedia } from '../entities/meaning.entity'; import type { ImageContentWarning } from '@/shared/constants/image-content-warnings'; import type { WordImageAttribution } from '@/shared/constants/word-image-attribution'; +import type { UsageLabel } from '@/shared/constants/usage-labels'; // status & isVerified & isCorrected di-override use case // (Section 22 - approval gate; resolvePublication) @@ -475,10 +476,15 @@ export interface WordRepository { ): Promise; /** - * taken_down → published. is_verified tidak diubah. Jejak takedown - * dikosongkan. false jika status bukan taken_down. - */ - restore(id: string, actorId: string): Promise; + /** taken_down → published. is_verified tidak diubah. Jejak takedown + * dikosongkan. false jika status bukan taken_down. + */ + restore(id: string, actorId: string): Promise; + + /** + * Update usageLabels on a word. Returns false if word not found. + */ + updateUsageLabels(id: string, usageLabels: UsageLabel[]): Promise; // ---- Kontribusi media (03-api-kontribusi-verifikasi.md) ---- diff --git a/src/modules/word/infrastructure/word.repository.impl.ts b/src/modules/word/infrastructure/word.repository.impl.ts index 130e0dae..33190d3e 100644 --- a/src/modules/word/infrastructure/word.repository.impl.ts +++ b/src/modules/word/infrastructure/word.repository.impl.ts @@ -2727,6 +2727,15 @@ export class WordRepositoryImpl implements WordRepository { return updated.length > 0; } + async updateUsageLabels(id: string, usageLabels: UsageLabel[]): Promise { + const [row] = await this.db + .update(words) + .set({ usageLabels }) + .where(and(eq(words.id, id), isNull(words.deletedAt))) + .returning({ id: words.id }); + return row !== undefined; + } + async restore(id: string, actorId: string): Promise { const now = new Date(); const updated = await this.db