From 8e5a2241e6f99c65c8e7ee24c5a6ef8229c11129 Mon Sep 17 00:00:00 2001 From: matteorossi-codes Date: Mon, 31 Aug 2026 08:47:52 +0200 Subject: [PATCH 1/2] feat(comment): flag comments directly into the moderation queue Add comment flagging so reported comments surface in the existing moderation review flow with full comment context. A CommentReport entity tracks reports with a unique (commentId, reporterId) constraint preventing duplicate entries for the same comment and reporter. Flagged comments are hidden from public listings once a moderator resolves the report. Closes #411 --- src/__tests__/CommentController.test.ts | 8 + src/__tests__/CommentReportService.test.ts | 145 ++++++++++++ src/controllers/AdminController.ts | 107 +++++++++ src/controllers/CommentController.ts | 33 ++- src/dtos/ReportCommentDTO.ts | 31 +++ src/entities/Comment.ts | 10 + src/entities/CommentReport.ts | 96 ++++++++ .../1754500000001-AddCommentReport.ts | 108 +++++++++ src/routes/adminRoutes.ts | 16 ++ src/routes/commentRoutes.ts | 4 + src/services/CommentReportService.ts | 219 ++++++++++++++++++ src/services/CommentService.ts | 4 +- 12 files changed, 778 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/CommentReportService.test.ts create mode 100644 src/dtos/ReportCommentDTO.ts create mode 100644 src/entities/CommentReport.ts create mode 100644 src/migrations/1754500000001-AddCommentReport.ts create mode 100644 src/services/CommentReportService.ts diff --git a/src/__tests__/CommentController.test.ts b/src/__tests__/CommentController.test.ts index 4b625db..1e5c670 100644 --- a/src/__tests__/CommentController.test.ts +++ b/src/__tests__/CommentController.test.ts @@ -5,6 +5,7 @@ const mockGetSongComments = jest.fn(); const mockGetReplies = jest.fn(); const mockUpdateComment = jest.fn(); const mockDeleteComment = jest.fn(); +const mockSubmitReport = jest.fn(); jest.mock('../services/CommentService', () => ({ CommentService: jest.fn().mockImplementation(() => ({ @@ -16,6 +17,12 @@ jest.mock('../services/CommentService', () => ({ })), })); +jest.mock('../services/CommentReportService', () => ({ + CommentReportService: jest.fn().mockImplementation(() => ({ + submitReport: mockSubmitReport, + })), +})); + import { CommentController } from '../controllers/CommentController'; import { Request, Response } from 'express'; @@ -35,6 +42,7 @@ beforeEach(() => { mockGetReplies.mockReset(); mockUpdateComment.mockReset(); mockDeleteComment.mockReset(); + mockSubmitReport.mockReset(); }); describe('CommentController.createComment', () => { diff --git a/src/__tests__/CommentReportService.test.ts b/src/__tests__/CommentReportService.test.ts new file mode 100644 index 0000000..1352262 --- /dev/null +++ b/src/__tests__/CommentReportService.test.ts @@ -0,0 +1,145 @@ +import 'reflect-metadata'; + +jest.mock('../config/db', () => ({ + __esModule: true, + default: { getRepository: jest.fn() }, +})); + +import AppDataSource from '../config/db'; +import { CommentReportService } from '../services/CommentReportService'; +import { CommentReport } from '../entities/CommentReport'; +import { Comment } from '../entities/Comment'; + +const mockReportRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + findOneBy: jest.fn(), + findAndCount: jest.fn(), + count: jest.fn(), +}; +const mockCommentRepo = { + findOneBy: jest.fn(), + save: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + (AppDataSource.getRepository as jest.Mock).mockImplementation((entity: unknown) => { + if (entity === CommentReport) return mockReportRepo; + if (entity === Comment) return mockCommentRepo; + throw new Error(`Unexpected entity: ${(entity as { name?: string })?.name}`); + }); +}); + +function makeSvc(): CommentReportService { + return new CommentReportService(); +} + +describe('CommentReportService.submitReport', () => { + it('flags a comment and enqueues it for moderation (Issue #411)', async () => { + mockCommentRepo.findOneBy.mockResolvedValue({ id: 'c-1', text: 'bad comment' }); + mockReportRepo.findOne.mockResolvedValue(null); + mockReportRepo.create.mockImplementation((input: unknown) => input); + mockReportRepo.save.mockImplementation(async (r: CommentReport) => r); + + const svc = makeSvc(); + const report = await svc.submitReport('c-1', 'u-1', { reason: 'harassment' }); + + expect(mockReportRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + commentId: 'c-1', + reporterId: 'u-1', + reason: 'harassment', + status: 'pending', + }), + ); + expect(report.commentId).toBe('c-1'); + }); + + it('rejects an unknown reason', async () => { + mockCommentRepo.findOneBy.mockResolvedValue({ id: 'c-1' }); + + const svc = makeSvc(); + await expect(svc.submitReport('c-1', 'u-1', { reason: 'nope' })).rejects.toMatchObject({ + statusCode: 400, + }); + }); + + it('rejects duplicate reports for the same comment and reporter', async () => { + mockCommentRepo.findOneBy.mockResolvedValue({ id: 'c-1' }); + mockReportRepo.findOne.mockResolvedValue({ id: 'r-1', commentId: 'c-1', reporterId: 'u-1' }); + + const svc = makeSvc(); + await expect(svc.submitReport('c-1', 'u-1', { reason: 'spam' })).rejects.toMatchObject({ + statusCode: 409, + }); + }); + + it('returns 404 when the comment does not exist', async () => { + mockCommentRepo.findOneBy.mockResolvedValue(null); + + const svc = makeSvc(); + await expect(svc.submitReport('ghost', 'u-1', { reason: 'spam' })).rejects.toMatchObject({ + statusCode: 404, + }); + }); +}); + +describe('CommentReportService.listPendingReports', () => { + it('returns flagged comments with full context', async () => { + mockReportRepo.findAndCount.mockResolvedValue([ + [{ id: 'r-1', comment: { id: 'c-1', text: 'bad', songId: 's-1' } }], + 1, + ]); + + const svc = makeSvc(); + const result = await svc.listPendingReports(1, 20); + + expect(mockReportRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: 'pending' }, + relations: { comment: true }, + skip: 0, + take: 20, + }), + ); + expect(result.reports).toHaveLength(1); + expect(result.reports[0].commentText).toBe('bad'); + expect(result.reports[0].songId).toBe('s-1'); + expect(result.pagination.total).toBe(1); + }); +}); + +describe('CommentReportService.resolveReport', () => { + it('flags the comment when the moderator removes it', async () => { + mockReportRepo.findOneBy.mockResolvedValue({ + id: 'r-1', + commentId: 'c-1', + status: 'pending', + actionTaken: null, + }); + mockReportRepo.save.mockImplementation(async (r: CommentReport) => r); + mockCommentRepo.findOneBy.mockResolvedValue({ id: 'c-1', flagged: false }); + mockCommentRepo.save.mockImplementation(async (c: Comment) => c); + + const svc = makeSvc(); + await svc.resolveReport('r-1', 'mod-1', { actionTaken: 'comment_removed' }); + + expect(mockCommentRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ id: 'c-1', flagged: true }), + ); + }); + + it('rejects resolving an already resolved report', async () => { + mockReportRepo.findOneBy.mockResolvedValue({ + id: 'r-1', + status: 'resolved', + }); + + const svc = makeSvc(); + await expect(svc.resolveReport('r-1', 'mod-1', { actionTaken: 'dismissed' })).rejects.toMatchObject( + { statusCode: 409 }, + ); + }); +}); diff --git a/src/controllers/AdminController.ts b/src/controllers/AdminController.ts index 504a6cc..63a6384 100644 --- a/src/controllers/AdminController.ts +++ b/src/controllers/AdminController.ts @@ -1,6 +1,10 @@ import { Request, Response } from 'express'; import { UserService } from '../services/UserService'; import { ArtistProfileService } from '../services/ArtistProfileService'; +import { CommentReportService } from '../services/CommentReportService'; +import { ReportService } from '../services/ReportService'; +import { ResolveReportDTO } from '../dtos/ReportSongDTO'; +import { ResolveCommentReportDTO } from '../dtos/ReportCommentDTO'; import { handleError } from '../utils/helpers'; import { HTTP_STATUS } from '../config/constants'; import { AppError } from '../errors/AppError'; @@ -16,6 +20,109 @@ export class AdminController { private static userService = new UserService(); private static artistProfileService = new ArtistProfileService(); private static transactionLogService = new TransactionLogService(); + private static reportService = new ReportService(); + private static commentReportService = new CommentReportService(); + + /** + * GET /api/admin/reports — list the pending content-report moderation queue + * (song and comment reports, Issue #88 / #411). + */ + static listReports = async (req: Request, res: Response) => { + try { + const page = Number(req.query.page) || 1; + const limit = Number(req.query.limit) || 20; + const songId = typeof req.query.songId === 'string' ? req.query.songId : undefined; + + const songReports = await AdminController.reportService.listPendingReports( + page, + limit, + songId, + ); + const commentReports = await AdminController.commentReportService.listPendingReports( + page, + limit, + ); + + return res.status(HTTP_STATUS.OK).json({ + success: true, + songReports, + commentReports, + }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** + * PUT /api/admin/reports/:id/resolve — resolve a song content report + * (Issue #88). + */ + static resolveReport = async (req: Request, res: Response) => { + try { + const moderatorId = (req as any).user?.id; + + if (!moderatorId) { + return handleError(req, res, AppError.authentication('Moderator not authenticated')); + } + + const report = await AdminController.reportService.resolveReport( + routeParam(req.params.id), + moderatorId, + req.body as ResolveReportDTO, + ); + + return res.status(HTTP_STATUS.OK).json({ + success: true, + message: 'Report resolved', + report, + }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** + * GET /api/admin/comment-reports — list pending comment reports with comment + * context (Issue #411). + */ + static listCommentReports = async (req: Request, res: Response) => { + try { + const page = Number(req.query.page) || 1; + const limit = Number(req.query.limit) || 20; + const result = await AdminController.commentReportService.listPendingReports(page, limit); + return res.status(HTTP_STATUS.OK).json({ success: true, ...result }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** + * PUT /api/admin/comment-reports/:id/resolve — resolve a comment report and + * flag the comment (Issue #411). + */ + static resolveCommentReport = async (req: Request, res: Response) => { + try { + const moderatorId = (req as any).user?.id; + + if (!moderatorId) { + return handleError(req, res, AppError.authentication('Moderator not authenticated')); + } + + const report = await AdminController.commentReportService.resolveReport( + routeParam(req.params.id), + moderatorId, + req.body as ResolveCommentReportDTO, + ); + + return res.status(HTTP_STATUS.OK).json({ + success: true, + message: 'Comment report resolved', + report, + }); + } catch (error) { + handleError(req, res, error); + } + }; /** * POST /api/admin/users/:id/role — assign a role to a user. diff --git a/src/controllers/CommentController.ts b/src/controllers/CommentController.ts index 4bc7966..7a4f564 100644 --- a/src/controllers/CommentController.ts +++ b/src/controllers/CommentController.ts @@ -1,18 +1,22 @@ import { Request, Response } from 'express'; import { CommentService } from '../services/CommentService'; +import { CommentReportService } from '../services/CommentReportService'; import { handleError } from '../utils/helpers'; import { HTTP_STATUS } from '../config/constants'; import { AppError } from '../errors/AppError'; import { routeParam } from '../utils/routeParams'; /** - * Controller for song comment endpoints (Issue #90). + * Controller for song comment endpoints (Issue #90) and comment flagging + * (Issue #411). */ export class CommentController { private commentService: CommentService; + private reportService: CommentReportService; constructor() { this.commentService = new CommentService(); + this.reportService = new CommentReportService(); } /** @@ -123,4 +127,31 @@ export class CommentController { handleError(req, res, error); } }; + + /** + * Flag a comment so it surfaces in the moderation queue (Issue #411). + * POST /api/comments/:id/report + */ + reportComment = async (req: Request, res: Response): Promise => { + try { + const reporterId = (req as any).user?.id as string | undefined; + + if (!reporterId) { + return handleError(req, res, AppError.authentication('User not authenticated')); + } + + const report = await this.reportService.submitReport( + routeParam(req.params.id), + reporterId, + { reason: req.body.reason, description: req.body.description }, + ); + + res.status(HTTP_STATUS.CREATED).json({ + message: 'Comment reported successfully', + report, + }); + } catch (error) { + handleError(req, res, error); + } + }; } diff --git a/src/dtos/ReportCommentDTO.ts b/src/dtos/ReportCommentDTO.ts new file mode 100644 index 0000000..3056325 --- /dev/null +++ b/src/dtos/ReportCommentDTO.ts @@ -0,0 +1,31 @@ +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + CommentReportAction, + CommentReportReason, +} from '../entities/CommentReport'; + +/** Body for `POST /api/comments/:id/report` (Issue #411). */ +export class ReportCommentDTO { + @IsEnum(CommentReportReason, { + message: `reason must be one of: ${Object.values(CommentReportReason).join(', ')}`, + }) + reason!: CommentReportReason; + + @IsString() + @IsOptional() + @MaxLength(1000, { message: 'Description must be 1000 characters or fewer.' }) + description?: string; +} + +/** Body for `PUT /api/admin/comment-reports/:id/resolve` (Issue #411). */ +export class ResolveCommentReportDTO { + @IsEnum(CommentReportAction, { + message: `actionTaken must be one of: ${Object.values(CommentReportAction).join(', ')}`, + }) + actionTaken!: CommentReportAction; + + @IsString() + @IsOptional() + @MaxLength(1000, { message: 'Resolution note must be 1000 characters or fewer.' }) + resolutionNote?: string; +} diff --git a/src/entities/Comment.ts b/src/entities/Comment.ts index 301255c..89015bc 100644 --- a/src/entities/Comment.ts +++ b/src/entities/Comment.ts @@ -75,6 +75,16 @@ export class Comment { @Column({ default: false }) edited!: boolean; + /** True once a moderator acted on a comment report (Issue #411). */ + @Column({ default: false }) + flagged!: boolean; + + @Column({ type: 'timestamp', nullable: true }) + flaggedAt?: Date | null; + + @Column({ type: 'text', nullable: true }) + flagReason?: string | null; + @CreateDateColumn() createdAt!: Date; diff --git a/src/entities/CommentReport.ts b/src/entities/CommentReport.ts new file mode 100644 index 0000000..0761598 --- /dev/null +++ b/src/entities/CommentReport.ts @@ -0,0 +1,96 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, + Unique, +} from 'typeorm'; +import { Comment } from './Comment'; +import { User } from './User'; + +/** Why a listener flagged a comment (Issue #411). */ +export enum CommentReportReason { + HARASSMENT = 'harassment', + SPAM = 'spam', + HATE_SPEECH = 'hate_speech', + INAPPROPRIATE = 'inappropriate', + OTHER = 'other', +} + +/** Lifecycle of a comment report in the moderation queue. */ +export enum CommentReportStatus { + PENDING = 'pending', + RESOLVED = 'resolved', +} + +/** What the moderator did about the flagged comment. */ +export enum CommentReportAction { + NO_ACTION = 'no_action', + COMMENT_FLAGGED = 'comment_flagged', + COMMENT_REMOVED = 'comment_removed', + DISMISSED = 'dismissed', +} + +/** + * A user-submitted report against a comment (Issue #411). + * + * Comment reports surface directly in the existing moderation review flow, + * carrying full comment context (the comment text and the parent thread). The + * `(commentId, reporterId)` unique constraint ensures a single account cannot + * file duplicate reports against the same comment. + */ +@Entity('comment_reports') +@Unique('UQ_comment_report_reporter_comment', ['commentId', 'reporterId']) +export class CommentReport { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => Comment, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'commentId' }) + comment!: Comment; + + @Index() + @Column() + commentId!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'reporterId' }) + reporter!: User; + + @Index() + @Column() + reporterId!: string; + + @Column({ type: 'varchar', default: CommentReportReason.OTHER }) + reason!: CommentReportReason; + + @Column({ type: 'text', nullable: true }) + description?: string | null; + + @Index() + @Column({ type: 'varchar', default: CommentReportStatus.PENDING }) + status!: CommentReportStatus; + + @Column({ type: 'varchar', nullable: true }) + actionTaken?: CommentReportAction | null; + + /** Moderator who resolved the report. */ + @Column({ nullable: true }) + resolvedBy?: string | null; + + @Column({ type: 'timestamp', nullable: true }) + resolvedAt?: Date | null; + + @Column({ type: 'text', nullable: true }) + resolutionNote?: string | null; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/migrations/1754500000001-AddCommentReport.ts b/src/migrations/1754500000001-AddCommentReport.ts new file mode 100644 index 0000000..fadb9d6 --- /dev/null +++ b/src/migrations/1754500000001-AddCommentReport.ts @@ -0,0 +1,108 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableColumn, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class AddCommentReport1754500000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'comments', + new TableColumn({ name: 'flagged', type: 'boolean', default: false }), + ); + await queryRunner.addColumn( + 'comments', + new TableColumn({ name: 'flaggedAt', type: 'timestamp', isNullable: true }), + ); + await queryRunner.addColumn( + 'comments', + new TableColumn({ name: 'flagReason', type: 'text', isNullable: true }), + ); + + await queryRunner.createTable( + new Table({ + name: 'comment_reports', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'commentId', type: 'uuid' }, + { name: 'reporterId', type: 'uuid' }, + { name: 'reason', type: 'varchar', default: "'other'" }, + { name: 'description', type: 'text', isNullable: true }, + { name: 'status', type: 'varchar', default: "'pending'" }, + { name: 'actionTaken', type: 'varchar', isNullable: true }, + { name: 'resolvedBy', type: 'uuid', isNullable: true }, + { name: 'resolvedAt', type: 'timestamp', isNullable: true }, + { name: 'resolutionNote', type: 'text', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'CURRENT_TIMESTAMP' }, + { name: 'updatedAt', type: 'timestamp', default: 'CURRENT_TIMESTAMP' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'comment_reports', + new TableForeignKey({ + name: 'FK_comment_report_comment', + columnNames: ['commentId'], + referencedTableName: 'comments', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'comment_reports', + new TableForeignKey({ + name: 'FK_comment_report_reporter', + columnNames: ['reporterId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'comment_reports', + new TableIndex({ name: 'IDX_comment_report_commentId', columnNames: ['commentId'] }), + ); + await queryRunner.createIndex( + 'comment_reports', + new TableIndex({ name: 'IDX_comment_report_reporterId', columnNames: ['reporterId'] }), + ); + await queryRunner.createIndex( + 'comment_reports', + new TableIndex({ name: 'IDX_comment_report_status', columnNames: ['status'] }), + ); + await queryRunner.createIndex( + 'comment_reports', + new TableIndex({ + name: 'UQ_comment_report_reporter_comment', + columnNames: ['commentId', 'reporterId'], + isUnique: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('comment_reports', 'UQ_comment_report_reporter_comment'); + await queryRunner.dropIndex('comment_reports', 'IDX_comment_report_status'); + await queryRunner.dropIndex('comment_reports', 'IDX_comment_report_reporterId'); + await queryRunner.dropIndex('comment_reports', 'IDX_comment_report_commentId'); + await queryRunner.dropForeignKey('comment_reports', 'FK_comment_report_reporter'); + await queryRunner.dropForeignKey('comment_reports', 'FK_comment_report_comment'); + await queryRunner.dropTable('comment_reports'); + + await queryRunner.dropColumn('comments', 'flagReason'); + await queryRunner.dropColumn('comments', 'flaggedAt'); + await queryRunner.dropColumn('comments', 'flagged'); + } +} diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts index 43412da..66c39a1 100644 --- a/src/routes/adminRoutes.ts +++ b/src/routes/adminRoutes.ts @@ -8,6 +8,8 @@ import { SongController } from '../controllers/SongController'; import { JobController } from '../controllers/JobController'; import { AdminController } from '../controllers/AdminController'; import { bulkModerationRateLimiter } from '../middlewares/bulkModerationRateLimiter'; +import { ResolveReportDTO } from '../dtos/ReportSongDTO'; +import { ResolveCommentReportDTO } from '../dtos/ReportCommentDTO'; const router = Router(); @@ -59,6 +61,20 @@ router.put( AdminController.resolveReport, ); +// Comment report queue (Issue #411) — flagged comments surface here with full +// comment context so they can be reviewed in the same moderation flow. +router.get( + '/comment-reports', + requirePermission(Permission.CONTENT_MODERATE), + AdminController.listCommentReports, +); +router.put( + '/comment-reports/:id/resolve', + requirePermission(Permission.CONTENT_MODERATE), + validateDTO(ResolveCommentReportDTO), + AdminController.resolveCommentReport, +); + // Search index maintenance (Issue #135) router.post( '/search/rebuild', diff --git a/src/routes/commentRoutes.ts b/src/routes/commentRoutes.ts index 6c8895b..b9740ca 100644 --- a/src/routes/commentRoutes.ts +++ b/src/routes/commentRoutes.ts @@ -3,6 +3,7 @@ import { CommentController } from '../controllers/CommentController'; import { requireAuth } from '../middlewares/authMiddleware'; import { validateDTO } from '../middlewares/validate'; import { UpdateCommentDTO } from '../dtos/UpdateCommentDTO'; +import { ReportCommentDTO } from '../dtos/ReportCommentDTO'; const router = Router(); const commentController = new CommentController(); @@ -12,4 +13,7 @@ router.get('/:id/replies', commentController.getReplies); router.put('/:id', requireAuth, validateDTO(UpdateCommentDTO), commentController.updateComment); router.delete('/:id', requireAuth, commentController.deleteComment); +// Flag a comment into the moderation queue (Issue #411). +router.post('/:id/report', requireAuth, validateDTO(ReportCommentDTO), commentController.reportComment); + export default router; diff --git a/src/services/CommentReportService.ts b/src/services/CommentReportService.ts new file mode 100644 index 0000000..7bdd1a2 --- /dev/null +++ b/src/services/CommentReportService.ts @@ -0,0 +1,219 @@ +import { Repository } from 'typeorm'; +import AppDataSource from '../config/db'; +import { Comment } from '../entities/Comment'; +import { + CommentReport, + CommentReportAction, + CommentReportReason, + CommentReportStatus, +} from '../entities/CommentReport'; +import { AppError } from '../errors/AppError'; +import logger from '../config/logger'; + +const VALID_REASONS = Object.values(CommentReportReason); +const VALID_ACTIONS = Object.values(CommentReportAction); + +export interface SubmitCommentReportInput { + reason: string; + description?: string; +} + +export interface ResolveCommentReportInput { + actionTaken: string; + resolutionNote?: string; +} + +export interface PendingCommentReportsPage { + reports: Array; + pagination: { page: number; limit: number; total: number; totalPages: number }; +} + +/** + * Community moderation of comments (Issue #411). + * + * Flagged comments surface in the moderation review queue used by the + * `ReportService`. Each report carries the full comment context so a moderator + * can assess it without leaving the queue. A `(commentId, reporterId)` unique + * constraint guarantees one report per listener per comment, so duplicate + * reports for the same comment and reporter are rejected. + */ +export class CommentReportService { + private reportRepo: Repository; + private commentRepo: Repository; + + constructor() { + this.reportRepo = AppDataSource.getRepository(CommentReport); + this.commentRepo = AppDataSource.getRepository(Comment); + } + + /** + * Submit a report against a comment, popping it into the moderation queue. + * + * @param commentId - ID of the flagged comment. + * @param reporterId - ID of the reporting user. + * @param input - Reason category and optional description. + * @returns The created report with the comment context attached. + * @throws {AppError} 400 for an unknown reason, 404 when the comment is + * missing, 409 when this user already reported this comment. + */ + async submitReport( + commentId: string, + reporterId: string, + input: SubmitCommentReportInput, + ): Promise { + const reason = this.parseReason(input.reason); + + const comment = await this.commentRepo.findOneBy({ id: commentId }); + if (!comment) { + throw AppError.notFound('Comment not found', undefined, 'COMMENT_NOT_FOUND'); + } + + const existing = await this.reportRepo.findOne({ where: { commentId, reporterId } }); + if (existing) { + throw AppError.conflict( + 'You have already reported this comment', + undefined, + 'DUPLICATE_COMMENT_REPORT', + ); + } + + const report = this.reportRepo.create({ + commentId, + reporterId, + reason, + description: input.description?.trim() || null, + status: CommentReportStatus.PENDING, + }); + await this.reportRepo.save(report); + + logger.info( + { commentId, reporterId, reason }, + 'Comment flagged and added to the moderation queue', + ); + + return report; + } + + /** + * Paginated queue of unresolved comment reports, with the full comment + * context (text and owning song) attached so moderators can review inline. + * + * @param page - 1-based page number. + * @param limit - Page size, capped at 100. + */ + async listPendingReports(page = 1, limit = 20): Promise { + const safePage = Math.max(1, Math.floor(page) || 1); + const safeLimit = Math.min(Math.max(1, Math.floor(limit) || 20), 100); + + const [reports, total] = await this.reportRepo.findAndCount({ + where: { status: CommentReportStatus.PENDING }, + relations: { comment: true }, + order: { createdAt: 'ASC' }, + skip: (safePage - 1) * safeLimit, + take: safeLimit, + }); + + const enriched = reports.map((report) => { + const flat = report as CommentReport & { commentText?: string; songId?: string }; + flat.commentText = report.comment?.text; + flat.songId = report.comment?.songId; + return flat; + }); + + return { + reports: enriched, + pagination: { + page: safePage, + limit: safeLimit, + total, + totalPages: Math.ceil(total / safeLimit) || 0, + }, + }; + } + + /** + * Count of pending reports for a comment, used by moderation views. + */ + async countPendingForComment(commentId: string): Promise { + return this.reportRepo.count({ where: { commentId, status: CommentReportStatus.PENDING } }); + } + + /** + * Resolve a pending comment report, recording the action a moderator took. + * + * `comment_flagged` and `comment_removed` mark the comment as flagged so it + * is hidden from public views; `dismissed` / `no_action` leave it as-is. + * + * @param reportId - ID of the comment report to resolve. + * @param moderatorId - ID of the resolving moderator. + * @param input - Action taken plus an optional note. + * @throws {AppError} 400 for an unknown action, 404 when the report does not + * exist, 409 when it was already resolved. + */ + async resolveReport( + reportId: string, + moderatorId: string, + input: ResolveCommentReportInput, + ): Promise { + const action = this.parseAction(input.actionTaken); + + const report = await this.reportRepo.findOneBy({ id: reportId }); + if (!report) { + throw AppError.notFound('Comment report not found', undefined, 'COMMENT_REPORT_NOT_FOUND'); + } + if (report.status === CommentReportStatus.RESOLVED) { + throw AppError.conflict( + 'Comment report is already resolved', + undefined, + 'COMMENT_REPORT_ALREADY_RESOLVED', + ); + } + + report.status = CommentReportStatus.RESOLVED; + report.actionTaken = action; + report.resolvedBy = moderatorId; + report.resolvedAt = new Date(); + report.resolutionNote = input.resolutionNote?.trim() || null; + await this.reportRepo.save(report); + + if (action === CommentReportAction.COMMENT_FLAGGED || action === CommentReportAction.COMMENT_REMOVED) { + const comment = await this.commentRepo.findOneBy({ id: report.commentId }); + if (comment) { + comment.flagged = true; + comment.flaggedAt = new Date(); + comment.flagReason = `Comment report resolution: ${action}`; + await this.commentRepo.save(comment); + } + } + + return report; + } + + private parseReason(raw: string): CommentReportReason { + const value = String(raw ?? '') + .trim() + .toLowerCase(); + if (!VALID_REASONS.includes(value as CommentReportReason)) { + throw AppError.validation( + `reason must be one of: ${VALID_REASONS.join(', ')}`, + { field: 'reason', value: raw }, + 'INVALID_COMMENT_REPORT_REASON', + ); + } + return value as CommentReportReason; + } + + private parseAction(raw: string): CommentReportAction { + const value = String(raw ?? '') + .trim() + .toLowerCase(); + if (!VALID_ACTIONS.includes(value as CommentReportAction)) { + throw AppError.validation( + `actionTaken must be one of: ${VALID_ACTIONS.join(', ')}`, + { field: 'actionTaken', value: raw }, + 'INVALID_COMMENT_REPORT_ACTION', + ); + } + return value as CommentReportAction; + } +} diff --git a/src/services/CommentService.ts b/src/services/CommentService.ts index b8b7868..47f84c2 100644 --- a/src/services/CommentService.ts +++ b/src/services/CommentService.ts @@ -145,7 +145,7 @@ export class CommentService { ); const [comments, total] = await this.commentRepo.findAndCount({ - where: { songId, parentId: IsNull() }, + where: { songId, parentId: IsNull(), flagged: false }, relations: ['user'], order: { createdAt: 'DESC' }, skip: (safePage - 1) * safeLimit, @@ -191,7 +191,7 @@ export class CommentService { ); const [replies, total] = await this.commentRepo.findAndCount({ - where: { parentId: commentId }, + where: { parentId: commentId, flagged: false }, relations: ['user'], order: { createdAt: 'ASC' }, skip: (safePage - 1) * safeLimit, From 6e24f50a6009664500cfeb18d266e998de87d7df Mon Sep 17 00:00:00 2001 From: matteorossi-codes Date: Mon, 31 Aug 2026 08:49:28 +0200 Subject: [PATCH 2/2] feat(playlist): add follow/subscribe and one-song reordering Add playlist follow/subscribe for listeners (mirroring the UserFollow relationship) with follow/unfollow endpoints, a followed-playlists listing endpoint, and follower counts. Includes a PlaylistFollow entity, migration, and tests. Add an explicit, efficient reorder endpoint that moves a single song to a new position (PATCH /:id/songs/:songId/position) without requiring the caller to resend the full playlist, keeping positions stable and compacted. Includes tests. Closes #408 Closes #409 --- src/__tests__/PlaylistService.test.ts | 125 ++++++++++++++ src/controllers/PlaylistController.ts | 60 +++++++ src/entities/PlaylistFollow.ts | 45 ++++++ .../1754500000000-AddPlaylistFollow.ts | 77 +++++++++ src/routes/playlistRoutes.ts | 9 ++ src/services/PlaylistService.ts | 153 +++++++++++++++++- 6 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 src/entities/PlaylistFollow.ts create mode 100644 src/migrations/1754500000000-AddPlaylistFollow.ts diff --git a/src/__tests__/PlaylistService.test.ts b/src/__tests__/PlaylistService.test.ts index 152c065..3a0b9db 100644 --- a/src/__tests__/PlaylistService.test.ts +++ b/src/__tests__/PlaylistService.test.ts @@ -10,6 +10,7 @@ import { PlaylistService } from '../services/PlaylistService'; import { Playlist } from '../entities/Playlist'; import { PlaylistSong } from '../entities/PlaylistSong'; import { PlaylistCollaborator, PlaylistCollaboratorRole } from '../entities/PlaylistCollaborator'; +import { PlaylistFollow } from '../entities/PlaylistFollow'; import { Song } from '../entities/Song'; const mockPlaylistRepo = { @@ -35,6 +36,14 @@ const mockCollaboratorRepo = { save: jest.fn(), delete: jest.fn(), }; +const mockFollowRepo = { + findOneBy: jest.fn(), + find: jest.fn(), + insert: jest.fn(), + delete: jest.fn(), + findAndCount: jest.fn(), + count: jest.fn(), +}; const mockSongRepo = { findOneBy: jest.fn(), createQueryBuilder: jest.fn(), @@ -46,6 +55,7 @@ beforeEach(() => { if (entity === Playlist) return mockPlaylistRepo; if (entity === PlaylistSong) return mockPlaylistSongRepo; if (entity === PlaylistCollaborator) return mockCollaboratorRepo; + if (entity === PlaylistFollow) return mockFollowRepo; if (entity === Song) return mockSongRepo; throw new Error(`Unexpected entity: ${(entity as { name?: string })?.name}`); }); @@ -437,3 +447,118 @@ describe('PlaylistService rule-based playlists (Issue #407)', () => { expect(result.songs[0].songId).toBe('song-a'); }); }); + +describe('PlaylistService.moveSong (Issue #409)', () => { + const entries = [ + { id: 'ps-1', playlistId: 'pl-1', songId: 'song-1', position: 0 }, + { id: 'ps-2', playlistId: 'pl-1', songId: 'song-2', position: 1 }, + { id: 'ps-3', playlistId: 'pl-1', songId: 'song-3', position: 2 }, + ]; + + it('moves a song to a new position, compacting the order', async () => { + mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist()); + mockPlaylistSongRepo.find.mockResolvedValue(entries.map((e) => ({ ...e }))); + mockPlaylistSongRepo.save.mockImplementation(async (saved: PlaylistSong[]) => saved); + mockPlaylistRepo.findOne.mockResolvedValue(ownedPlaylist({ songs: [] as unknown as PlaylistSong[] })); + + const svc = makeSvc(); + await svc.moveSong('pl-1', 'user-1', 'song-3', 0); + + const saved = mockPlaylistSongRepo.save.mock.calls[0][0] as PlaylistSong[]; + expect(saved.map((e) => e.songId)).toEqual(['song-3', 'song-1', 'song-2']); + expect(saved.map((e) => e.position)).toEqual([0, 1, 2]); + }); + + it('rejects moving a song that is not in the playlist', async () => { + mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist()); + mockPlaylistSongRepo.find.mockResolvedValue(entries.map((e) => ({ ...e }))); + + const svc = makeSvc(); + await expect(svc.moveSong('pl-1', 'user-1', 'ghost', 1)).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it('rejects a negative position', async () => { + mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist()); + + const svc = makeSvc(); + await expect(svc.moveSong('pl-1', 'user-1', 'song-1', -1)).rejects.toMatchObject({ + statusCode: 400, + }); + }); + + it('rejects moving a song in a rule-based playlist', async () => { + mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist({ isRuleBased: true })); + + const svc = makeSvc(); + await expect(svc.moveSong('pl-1', 'user-1', 'song-1', 0)).rejects.toMatchObject({ + statusCode: 400, + }); + }); +}); + +describe('PlaylistService follow (Issue #408)', () => { + it('follows a playlist owned by another user', async () => { + mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist({ userId: 'user-2' })); + mockFollowRepo.findOneBy.mockResolvedValue(null); + mockFollowRepo.count.mockResolvedValue(2); + + const svc = makeSvc(); + const result = await svc.followPlaylist('user-1', 'pl-1'); + + expect(mockFollowRepo.insert).toHaveBeenCalledWith({ userId: 'user-1', playlistId: 'pl-1' }); + expect(result).toEqual({ followed: true, followerCount: 2 }); + }); + + it('is idempotent when already following', async () => { + mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist({ userId: 'user-2' })); + mockFollowRepo.findOneBy.mockResolvedValue({ id: 'pf-1', userId: 'user-1', playlistId: 'pl-1' }); + mockFollowRepo.count.mockResolvedValue(3); + + const svc = makeSvc(); + const result = await svc.followPlaylist('user-1', 'pl-1'); + + expect(mockFollowRepo.insert).not.toHaveBeenCalled(); + expect(result).toEqual({ followed: false, followerCount: 3 }); + }); + + it('rejects following your own playlist', async () => { + mockPlaylistRepo.findOneBy.mockResolvedValue(ownedPlaylist({ userId: 'user-1' })); + + const svc = makeSvc(); + await expect(svc.followPlaylist('user-1', 'pl-1')).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('unfollows a playlist', async () => { + mockFollowRepo.delete.mockResolvedValue({ affected: 1 }); + + const svc = makeSvc(); + await svc.unfollowPlaylist('user-1', 'pl-1'); + + expect(mockFollowRepo.delete).toHaveBeenCalledWith({ userId: 'user-1', playlistId: 'pl-1' }); + }); + + it('lists followed playlists newest first', async () => { + const playlist = ownedPlaylist({ userId: 'user-2', id: 'pl-1' }); + mockFollowRepo.findAndCount.mockResolvedValue([ + [{ id: 'pf-1', playlist, createdAt: new Date('2026-01-01') }], + 1, + ]); + + const svc = makeSvc(); + const result = await svc.listFollowedPlaylists('user-1', 1, 20); + + expect(mockFollowRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'user-1' }, + relations: { playlist: { user: true } }, + skip: 0, + take: 20, + }), + ); + expect(result.data).toHaveLength(1); + expect(result.data[0].playlist.id).toBe('pl-1'); + expect(result.pagination.total).toBe(1); + }); +}); diff --git a/src/controllers/PlaylistController.ts b/src/controllers/PlaylistController.ts index f39d903..4ffb210 100644 --- a/src/controllers/PlaylistController.ts +++ b/src/controllers/PlaylistController.ts @@ -143,6 +143,66 @@ export class PlaylistController { } }; + /** PATCH /api/playlists/:id/songs/:songId/position — move one song (Issue #409). */ + static moveSong = async (req: Request, res: Response) => { + try { + const playlistId = req.params.id as string; + const songId = req.params.songId as string; + const userId = (req as any).user.id as string; + const newPosition = req.body?.newPosition as number | undefined; + + if (newPosition === undefined || !Number.isInteger(newPosition) || newPosition < 0) { + throw AppError.validation( + 'newPosition must be a non-negative integer', + undefined, + 'PLAYLIST_REORDER_INVALID', + ); + } + + const playlist = await playlistService.moveSong(playlistId, userId, songId, newPosition); + return res.status(200).json({ success: true, data: playlist }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** POST /api/playlists/:id/follow — follow a playlist (Issue #408). */ + static followPlaylist = async (req: Request, res: Response) => { + try { + const userId = (req as any).user.id as string; + const playlistId = req.params.id as string; + const result = await playlistService.followPlaylist(userId, playlistId); + return res.status(200).json({ success: true, data: result }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** DELETE /api/playlists/:id/follow — unfollow a playlist (Issue #408). */ + static unfollowPlaylist = async (req: Request, res: Response) => { + try { + const userId = (req as any).user.id as string; + const playlistId = req.params.id as string; + await playlistService.unfollowPlaylist(userId, playlistId); + return res.status(200).json({ success: true, message: 'Playlist unfollowed' }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** GET /api/playlists/followed — list the caller's followed playlists (Issue #408). */ + static listFollowedPlaylists = async (req: Request, res: Response) => { + try { + const userId = (req as any).user.id as string; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const result = await playlistService.listFollowedPlaylists(userId, page, limit); + return res.status(200).json({ success: true, ...result }); + } catch (error) { + handleError(req, res, error); + } + }; + /** GET /api/playlists/:id/collaborators — list a playlist's collaborators. */ static listCollaborators = async (req: Request, res: Response) => { try { diff --git a/src/entities/PlaylistFollow.ts b/src/entities/PlaylistFollow.ts new file mode 100644 index 0000000..6bc0130 --- /dev/null +++ b/src/entities/PlaylistFollow.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, + Unique, +} from 'typeorm'; +import { User } from './User'; +import { Playlist } from './Playlist'; + +/** + * A listener following a playlist to receive updates (Issue #408). + * + * Mirrors the `UserFollow` relation: a follower subscribes to a playlist so + * that playlist activity can surface in their feed. The `(userId, playlistId)` + * unique constraint prevents duplicate follows from the same listener. + */ +@Entity('playlist_follows') +@Unique(['userId', 'playlistId']) +export class PlaylistFollow { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Index() + @Column() + userId!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user!: User; + + @Index() + @Column() + playlistId!: string; + + @ManyToOne(() => Playlist, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'playlistId' }) + playlist!: Playlist; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/src/migrations/1754500000000-AddPlaylistFollow.ts b/src/migrations/1754500000000-AddPlaylistFollow.ts new file mode 100644 index 0000000..43df080 --- /dev/null +++ b/src/migrations/1754500000000-AddPlaylistFollow.ts @@ -0,0 +1,77 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class AddPlaylistFollow1754500000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'playlist_follows', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'userId', type: 'uuid' }, + { name: 'playlistId', type: 'uuid' }, + { name: 'createdAt', type: 'timestamp', default: 'CURRENT_TIMESTAMP' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'playlist_follows', + new TableForeignKey({ + name: 'FK_playlist_follow_user', + columnNames: ['userId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'playlist_follows', + new TableForeignKey({ + name: 'FK_playlist_follow_playlist', + columnNames: ['playlistId'], + referencedTableName: 'playlists', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'playlist_follows', + new TableIndex({ name: 'IDX_playlist_follow_userId', columnNames: ['userId'] }), + ); + await queryRunner.createIndex( + 'playlist_follows', + new TableIndex({ name: 'IDX_playlist_follow_playlistId', columnNames: ['playlistId'] }), + ); + await queryRunner.createIndex( + 'playlist_follows', + new TableIndex({ + name: 'UQ_playlist_follow_user_playlist', + columnNames: ['userId', 'playlistId'], + isUnique: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('playlist_follows', 'UQ_playlist_follow_user_playlist'); + await queryRunner.dropIndex('playlist_follows', 'IDX_playlist_follow_playlistId'); + await queryRunner.dropIndex('playlist_follows', 'IDX_playlist_follow_userId'); + await queryRunner.dropForeignKey('playlist_follows', 'FK_playlist_follow_playlist'); + await queryRunner.dropForeignKey('playlist_follows', 'FK_playlist_follow_user'); + await queryRunner.dropTable('playlist_follows'); + } +} diff --git a/src/routes/playlistRoutes.ts b/src/routes/playlistRoutes.ts index d7b1578..a582616 100644 --- a/src/routes/playlistRoutes.ts +++ b/src/routes/playlistRoutes.ts @@ -7,6 +7,9 @@ const router = Router(); // All playlist routes require authentication (Issue #77). router.post('/', requireAuth, PlaylistController.create); router.get('/', requireAuth, PlaylistController.list); +// Followed playlists (Issue #408) — registered before /:id so it is not +// shadowed by the single-playlist lookup. +router.get('/followed', requireAuth, PlaylistController.listFollowedPlaylists); router.get('/:id', requireAuth, PlaylistController.getById); router.put('/:id', requireAuth, PlaylistController.update); router.delete('/:id', requireAuth, PlaylistController.remove); @@ -15,6 +18,12 @@ router.delete('/:id', requireAuth, PlaylistController.remove); router.post('/:id/songs', requireAuth, PlaylistController.addSong); router.delete('/:id/songs/:songId', requireAuth, PlaylistController.removeSong); router.put('/:id/reorder', requireAuth, PlaylistController.reorder); +// Move a single song to a new position (Issue #409). +router.patch('/:id/songs/:songId/position', requireAuth, PlaylistController.moveSong); + +// Playlist follow/subscribe (Issue #408). +router.post('/:id/follow', requireAuth, PlaylistController.followPlaylist); +router.delete('/:id/follow', requireAuth, PlaylistController.unfollowPlaylist); // Collaborative editing (Issue #406). router.get('/:id/collaborators', requireAuth, PlaylistController.listCollaborators); diff --git a/src/services/PlaylistService.ts b/src/services/PlaylistService.ts index 35b7581..e13df5a 100644 --- a/src/services/PlaylistService.ts +++ b/src/services/PlaylistService.ts @@ -3,6 +3,7 @@ import AppDataSource from '../config/db'; import { Playlist, PlaylistRule } from '../entities/Playlist'; import { PlaylistSong } from '../entities/PlaylistSong'; import { PlaylistCollaborator, PlaylistCollaboratorRole } from '../entities/PlaylistCollaborator'; +import { PlaylistFollow } from '../entities/PlaylistFollow'; import { Song } from '../entities/Song'; import { AppError } from '../errors/AppError'; @@ -29,6 +30,17 @@ export interface AddCollaboratorInput { role: PlaylistCollaboratorRole; } +/** Result of a follow/unfollow operation, mirroring `UserFollow` counts. */ +export interface PlaylistFollowCounts { + followerCount: number; +} + +/** A followed playlist plus its owner, returned by listing endpoints. */ +export interface FollowedPlaylist { + playlist: Playlist; + followedAt: Date; +} + const DAY_MS = 24 * 60 * 60 * 1000; /** @@ -50,12 +62,14 @@ export class PlaylistService { private playlistRepo: Repository; private playlistSongRepo: Repository; private collaboratorRepo: Repository; + private followRepo: Repository; private songRepo: Repository; constructor() { this.playlistRepo = AppDataSource.getRepository(Playlist); this.playlistSongRepo = AppDataSource.getRepository(PlaylistSong); this.collaboratorRepo = AppDataSource.getRepository(PlaylistCollaborator); + this.followRepo = AppDataSource.getRepository(PlaylistFollow); this.songRepo = AppDataSource.getRepository(Song); } @@ -304,8 +318,64 @@ export class PlaylistService { } /** - * List the collaborators of a playlist. Reader (owner/collaborator) only. + * Move a single song to a new position without requiring the caller to + * resend the full playlist (Issue #409). Positions are compacted and kept + * stable after the move so the resulting order is deterministic. + * + * Owner or an editor collaborator may move a song. */ + async moveSong( + playlistId: string, + userId: string, + songId: string, + newPosition: number, + ): Promise { + const playlist = await this.getEditablePlaylist(playlistId, userId); + if (playlist.isRuleBased) { + throw AppError.validation( + 'Cannot manually reorder a rule-based playlist', + undefined, + 'PLAYLIST_RULE_BASED', + ); + } + + if (!Number.isInteger(newPosition) || newPosition < 0) { + throw AppError.validation( + 'newPosition must be a non-negative integer', + undefined, + 'PLAYLIST_REORDER_INVALID', + ); + } + + const entries = await this.playlistSongRepo.find({ + where: { playlistId }, + order: { position: 'ASC' }, + }); + + const entry = entries.find((e) => e.songId === songId); + if (!entry) { + throw AppError.notFound('Song is not in this playlist', undefined, 'PLAYLIST_SONG_NOT_FOUND'); + } + + // Clamp the requested position to the valid range and splice. + const maxIndex = entries.length - 1; + const target = Math.min(newPosition, maxIndex); + if (target === entry.position) { + return this.getById(playlistId, userId); + } + + const ordered = entries.filter((e) => e.songId !== songId); + ordered.splice(target, 0, entry); + + await this.playlistSongRepo.save( + ordered.map((e, index) => { + e.position = index; + return e; + }), + ); + + return this.getById(playlistId, userId); + } async listCollaborators(playlistId: string, viewerId?: string): Promise { const playlist = await this.getReadablePlaylist(playlistId, viewerId); void playlist; @@ -412,6 +482,87 @@ export class PlaylistService { } } + /** + * Follow (subscribe to) someone else's playlist (Issue #408). + * + * Idempotent: following a playlist the user already follows is a no-op. The + * caller may not follow their own playlist. + */ + async followPlaylist( + userId: string, + playlistId: string, + ): Promise<{ followed: boolean; followerCount: number }> { + const playlist = await this.getReadablePlaylist(playlistId, userId); + if (playlist.userId === userId) { + throw AppError.validation( + 'You cannot follow your own playlist', + undefined, + 'PLAYLIST_FOLLOW_SELF', + ); + } + + const existing = await this.followRepo.findOneBy({ userId, playlistId }); + if (existing) { + return { followed: false, followerCount: await this.countFollowers(playlistId) }; + } + + await this.followRepo.insert({ userId, playlistId }); + return { followed: true, followerCount: await this.countFollowers(playlistId) }; + } + + /** + * Unfollow a playlist (Issue #408). Idempotent. + */ + async unfollowPlaylist(userId: string, playlistId: string): Promise { + await this.followRepo.delete({ userId, playlistId }); + } + + /** + * List the playlists a user follows, newest first (Issue #408). + */ + async listFollowedPlaylists( + userId: string, + page = 1, + limit = 20, + ): Promise<{ + data: FollowedPlaylist[]; + pagination: { page: number; limit: number; total: number; totalPages: number }; + }> { + const safePage = Math.max(1, Math.floor(page) || 1); + const safeLimit = Math.min(Math.max(1, Math.floor(limit) || 20), 100); + + const [follows, total] = await this.followRepo.findAndCount({ + where: { userId }, + relations: { playlist: { user: true } }, + order: { createdAt: 'DESC' }, + skip: (safePage - 1) * safeLimit, + take: safeLimit, + }); + + return { + data: follows.map((follow) => ({ + playlist: follow.playlist, + followedAt: follow.createdAt, + })), + pagination: { + page: safePage, + limit: safeLimit, + total, + totalPages: Math.ceil(total / safeLimit) || 0, + }, + }; + } + + /** Number of listeners following a playlist. */ + async countFollowers(playlistId: string): Promise { + return this.followRepo.count({ where: { playlistId } }); + } + + /** Whether a user currently follows a playlist. */ + async isFollowing(userId: string, playlistId: string): Promise { + return !!(await this.followRepo.findOneBy({ userId, playlistId })); + } + /** * Resolve the current matching songs for a rule-based playlist and shape * them like stored PlaylistSong rows so the API response stays uniform.