Moderation
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”การเพิ่มเข้าไปใน apps/api/src/comments/comments.resolver.ts: moderateComment ที่เป็น mutation แบบ admin-only ที่ guard ด้วย @UseGuards(GqlAuthGuard, RolesGuard) และ @Roles('admin') guard stack เดียวกับที่ Posts resolver เคยใช้กับ deletePost เป๊ะ ไม่มีการเปลี่ยนไฟล์อื่นเลย — comments, addComment, CommentsService และ PostCommentsResolver ยังคง shape ที่ The comment model วางไว้ทั้งหมด บทเรียนนี้แค่เพิ่ม operation เดียวที่ admin ต้องการเพื่อย้ายคอมเมนต์ออกจาก pending
คอมเมนต์ทุกอันที่ The comment model สร้างขึ้นเข้ามาที่ status: 'pending' และจะมองไม่เห็นในมุมมองเริ่มต้นของ comments ตลอดไป จนกว่าจะมีอะไรมาเปลี่ยน status นั้นอย่างชัดเจน moderateComment คือตัวนั้น: รับ id ของคอมเมนต์กับ CommentStatus ปลายทาง แล้วอัปเดตแค่ field เดียว ไม่มี approveComment/rejectComment แยกเป็นคู่ เพราะ mutation ตัวเดียวที่ parameterize ด้วย status ปลายทางครอบคลุมทั้งสองผลลัพธ์ของการตัดสินใจเรื่องเดียวกันอยู่แล้ว เหมือนกับที่ PostsService.findPage รับ argument status แทนที่ DevBlog จะมี query method แยกต่อค่า PostStatus แต่ละค่า
guard stack สำคัญตรงนี้ ในแบบที่ไม่สำคัญเลยกับ comments เพราะ comments ต้องเข้าถึงได้จากผู้เรียกที่ไม่ล็อกอิน จึงใช้ OptionalGqlAuthGuard แล้วคุมพฤติกรรมด้วย if ธรรมดาข้างใน resolver ส่วน moderateComment ไม่มีเคสสาธารณะที่เทียบเท่าเลย การเรียก “เปลี่ยน status ของคอมเมนต์นี้” แบบไม่ล็อกอินหรือไม่ใช่ admin ไม่มีความหมายอะไรทั้งนั้น จึงใช้คู่ guard ที่เข้มงวดจาก Guards & roles: GqlAuthGuard ปฏิเสธ request ที่ไม่มี token ใช้ได้ตั้งแต่ก่อน resolver จะรัน และ RolesGuard บวก @Roles('admin') ปฏิเสธ token ที่ใช้ได้แต่ไม่ใช่ admin ตั้งแต่เนิ่น ๆ เหมือนกัน
เทียบกับ comments ที่ไม่ปฏิเสธใครเลย ทำได้แค่จำกัดว่าคนที่ไม่ใช่ admin หรือ admin ที่ไม่ได้ขออะไรเจาะจงจะเห็นอะไรบ้าง สอง operation บน Comment type เดียวกัน แต่มี privilege shape คนละแบบ เพราะ “อ่านเธรดสาธารณะ” กับ “ตัดสินว่าอะไรจะได้เข้าเธรดสาธารณะ” เป็นคนละคำถามกันจริง ๆ และมีคำตอบต่างกันสำหรับผู้เรียกที่ไม่ล็อกอิน ข้อแรกต้องสำเร็จเสมอ ข้อสองต้องไม่มีวันได้เริ่มทำงานด้วยซ้ำ
Moderation ที่สร้างไว้ตรงนี้เป็นแบบ manual และเชิงรับล้วน ๆ คือคอมเมนต์ค้างอยู่ใน pending จนกว่า admin จะเห็นแล้วลงมือทำ มีส่วนขยายจริงสามอย่างที่ควรพูดถึง แม้คอร์สนี้จะไม่ได้สร้างให้
Spam prevention — heuristic ง่าย ๆ (เกณฑ์จำนวนลิงก์, denylist ของวลีที่รู้ว่าไม่ดี หรือบริการ third-party อย่าง Akismet) รันข้างใน CommentsService.add ตั้งแต่ก่อน persist คอมเมนต์เป็น pending ด้วยซ้ำ spam ที่ชัด ๆ จะได้ไม่ไปถึงคิวของ admin เลย
Rate limiting — ตอนนี้ยังไม่มีอะไรห้ามผู้เยี่ยมชมคนเดียวส่งคอมเมนต์เป็นร้อยครั้งต่อนาที การ throttle ต่อ IP หรือต่อ authorEmail บน addComment (@nestjs/throttler เหมาะกับงานนี้พอดี) จะปิดช่องนี้ได้
Email notification เมื่อมีคอมเมนต์ใหม่ — ตอนนี้ admin จะรู้ว่ามีคอมเมนต์ pending ก็ต่อเมื่อ poll comments(status: PENDING) เท่านั้น การใส่ hook ใน CommentsService.add ให้ส่งอีเมลหา admin (หรือ author ของโพสต์) ทันทีที่คอมเมนต์เข้ามา จะเปลี่ยน moderation จาก “เช็คเป็นระยะ” เป็น “ตอบสนองเมื่อได้รับแจ้ง”
ทั้งสามข้อไม่มีข้อไหนเปลี่ยน shape ของ moderateComment เลย ทุกอย่างอยู่ต้นน้ำขึ้นไป ตรงจุดที่คอมเมนต์ถูกสร้างหรือเข้าคิว
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”การลดระดับ (downgrade) argument status ของผู้ที่ไม่ใช่ admin อย่างเงียบ ๆ (module นี้) เทียบกับการ throw แบบที่ posts(status: DRAFT) ทำกับผู้ที่ไม่ใช่ author (Content Workflow) Draft → published ทำให้ posts throw 403 ForbiddenException ทันทีที่ผู้เรียกที่ไม่ยืนยันตัวตนขอ status: DRAFT เป็นสัญญาณที่ชัดและ debug ได้ว่า request โดนปฏิเสธด้วยเหตุผลเจาะจง ส่วน comments จาก The comment model ทำตรงข้ามโดยตั้งใจ: คนที่ไม่ใช่ admin ส่ง status: PENDING มาจะได้ผลลัพธ์ APPROVED กลับไปโดยไม่มี error ราวกับไม่เคยส่ง argument นั้นมาเลย
การ throw เป็นทางที่ตรงไปตรงมากว่า เพราะ client รู้ทันทีว่าขอสิ่งที่ตัวเองไม่มีสิทธิ์ แต่การแทนที่แบบเงียบ ๆ ปลอดภัยกว่าสำหรับ query ที่ browser ของผู้เยี่ยมชมทุกคนเรียกทุกครั้งที่โหลดหน้าโพสต์ comments ใกล้เคียงกับ widget เนื้อหาสาธารณะมากกว่า workflow การเขียน และผู้เรียกที่ลองยิง status: REJECTED เข้ามาแค่อยากรู้ว่าจะเกิดอะไรขึ้น ไม่ควรได้ error response ที่เท่ากับยืนยันว่ามี code path พิเศษอยู่จริง ควรให้ดูเหมือนว่า argument นั้นไม่ได้ทำอะไรเลยมากกว่า
posts กับ comments จึงได้ข้อสรุปตรงข้ามกันบนคำถามประเภทเดียวกัน เพราะข้อหนึ่งเป็นเครื่องมือ workflow สำหรับผู้ใช้ที่ยืนยันตัวตนแล้ว ส่วนอีกข้อเป็นเส้นทาง render สาธารณะ ไม่ใช่เพราะสองบทเรียนขัดกันเอง
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”อัปเดต apps/api/src/comments/comments.resolver.ts เพื่อเพิ่ม moderateComment:
import { UseGuards } from '@nestjs/common';import { Args, ID, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';import { CommentsService } from './comments.service';import { Comment } from './models/comment.model';import { CommentStatus } from './enums/comment-status.enum';import { AddCommentInput } from './dto/add-comment.input';import { CommentDocument } from './schemas/comment.schema';import { GqlAuthGuard } from '../auth/gql-auth.guard';import { OptionalGqlAuthGuard } from '../auth/optional-gql-auth.guard';import { RolesGuard } from '../auth/roles.guard';import { Roles } from '../auth/roles.decorator';import { CurrentUser } from '../auth/current-user.decorator';
interface AuthenticatedUser { userId: string; email: string; role: 'author' | 'admin';}
@Resolver(() => Comment)export class CommentsResolver { constructor(private readonly commentsService: CommentsService) {}
@Mutation(() => Comment) addComment( @Args('postId', { type: () => ID }) postId: string, @Args('input') input: AddCommentInput, ): Promise<CommentDocument> { return this.commentsService.add(postId, input); }
@Query(() => [Comment]) @UseGuards(OptionalGqlAuthGuard) comments( @Args('postId', { type: () => ID }) postId: string, @Args('status', { type: () => CommentStatus, nullable: true }) status?: CommentStatus, @CurrentUser() currentUser?: AuthenticatedUser, ): Promise<CommentDocument[]> { const isAdmin = currentUser?.role === 'admin'; const effectiveStatus = isAdmin && status ? status : CommentStatus.APPROVED; return this.commentsService.findByPost(postId, effectiveStatus); }
@Mutation(() => Comment) @UseGuards(GqlAuthGuard, RolesGuard) @Roles('admin') moderateComment( @Args('id', { type: () => ID }) id: string, @Args('status', { type: () => CommentStatus }) status: CommentStatus, ): Promise<CommentDocument> { return this.commentsService.moderate(id, status); }
@ResolveField(() => ID) postId(@Parent() comment: CommentDocument): string { return comment.post.toString(); }}moderateCommentรับstatus: CommentStatus!โดยไม่มีnullable: true— ต่างจาก argumentstatusที่เป็น optional ของcommentsไม่มีการเรียก “moderate ไปยังไม่มี status เจาะจง” ที่มีความหมายอะไรเลย argument นี้จึงเป็น required@UseGuards(GqlAuthGuard, RolesGuard)ก่อน@Roles('admin')— กฎการเรียงลำดับเดียวกับที่ Guards & roles วางไว้แล้วสำหรับdeletePost:RolesGuardอ่านreq.user.roleซึ่งจะมีก็ต่อเมื่อGqlAuthGuardรันไปก่อนแล้วและแนบค่านั้นไว้ให้- ไม่มี branch ไม่มี
ifในmoderateCommentเอง การตัดสินใจเรื่องสิทธิ์เข้าถึงเกิดใน guard ไปหมดแล้วก่อน body ของ method นี้จะรัน ตัว resolver จึงเหลือเป็น one-line delegation แบบเดียวกับที่ Refactoring pass คืนให้PostsResolver.postsหลังจากที่ access-control logic เคยไปนั่งอยู่ใน resolver ชั่วคราว
อัปเดต apps/api/src/comments/comments.service.ts เพื่อเพิ่ม moderate:
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';import { InjectModel } from '@nestjs/mongoose';import { Model } from 'mongoose';import { Comment, CommentDocument } from './schemas/comment.schema';import { CommentStatus } from './enums/comment-status.enum';import { AddCommentInput } from './dto/add-comment.input';import { PostsService } from '../posts/posts.service';
@Injectable()export class CommentsService { constructor( @InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>, private readonly postsService: PostsService, ) {}
async add(postId: string, input: AddCommentInput): Promise<CommentDocument> { const post = await this.postsService.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } if (post.status !== 'published') { throw new ForbiddenException('Cannot comment on a post that is not published'); }
const created = new this.commentModel({ ...input, post: postId, status: CommentStatus.PENDING, }); return created.save(); }
findByPost(postId: string, status?: CommentStatus): Promise<CommentDocument[]> { const filter: Record<string, unknown> = { post: postId }; if (status) { filter.status = status; } return this.commentModel.find(filter).sort({ createdAt: -1 }).exec(); }
async moderate(id: string, status: CommentStatus): Promise<CommentDocument> { const updated = await this.commentModel.findByIdAndUpdate(id, { status }, { new: true }).exec(); if (!updated) { throw new NotFoundException('Comment not found'); } return updated; }}moderate ทำตาม shape findByIdAndUpdate + null-guard + NotFoundException เดียวกับที่ PostsService.publish เคยวางไว้แล้วใน Posts resolver เป๊ะ ๆ เพราะ moderation กับ publish เป็น operation ประเภทเดียวกันข้างใต้ คือพลิก status field เดียวบน document ที่มีอยู่ หรือ fail ด้วย 404 ถ้าไม่มี document นั้น
เส้นทางที่ admin ใช้ดูผ่าน comments ไม่ต้องเขียนโค้ดใหม่เลย เพราะเขียนไว้ใน CommentsResolver.comments ตั้งแต่ The comment model แล้ว:
comments( @Args('postId', { type: () => ID }) postId: string, @Args('status', { type: () => CommentStatus, nullable: true }) status?: CommentStatus, @CurrentUser() currentUser?: AuthenticatedUser,): Promise<CommentDocument[]> { const isAdmin = currentUser?.role === 'admin'; const effectiveStatus = isAdmin && status ? status : CommentStatus.APPROVED; return this.commentsService.findByPost(postId, effectiveStatus);}ตอนนี้เพิ่งจะมีเหตุผลจริง ๆ ให้เรียกด้วย status: PENDING จึงคุ้มที่จะไล่ดูให้ชัด OptionalGqlAuthGuard รันก่อน แล้วเติม @CurrentUser() จาก header Authorization ที่ใช้ได้จริง หรือปล่อยเป็น undefined ถ้าไม่มี header โดยไม่ปฏิเสธ request ในทางใดเลย ค่า isAdmin เป็น true ก็ต่อเมื่อมี currentUser และ role เป็น 'admin' เท่านั้น ส่วน effectiveStatus จะกลายเป็น status ที่ผู้เรียกขอมาก็ต่อเมื่อ isAdmin เป็น true และ ส่ง status มาจริงเท่านั้น การผสมแบบอื่นทั้งหมด — ไม่มี token, token ของ author, หรือ token ของ admin ที่ไม่ส่ง argument status — ตกไปที่ CommentStatus.APPROVED หมด admin ที่อยากได้คิว moderation จึงต้องทั้งล็อกอินเป็น admin และเจาะจงขอ PENDING ไม่มีทางหลุดไปเจออะไรนอกจากมุมมองสาธารณะโดยบังเอิญได้เลย
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”npm run start:devใช้ header Authorization ของ admin ใน Sandbox (บัญชีที่มี role: "admin" — ดู Guards & roles ถ้าต้องเช็คว่า role ของผู้ใช้ถูกตั้งค่ายังไง) แสดงรายการคอมเมนต์ pending ที่สร้างไว้ใน Verify section ของ The comment model:
query PendingComments { comments(postId: "<same post id>", status: PENDING) { id authorName status }}{ "data": { "comments": [ { "id": "...", "authorName": "Alex", "status": "PENDING" } ] }}Approve คอมเมนต์นั้น:
mutation ApproveComment { moderateComment(id: "<the comment id above>", status: APPROVED) { id status }}{ "data": { "moderateComment": { "id": "...", "status": "APPROVED" } }}ตอนนี้รัน query comments สาธารณะอีกครั้ง โดยไม่ใส่ header Authorization เป็น query เดียวกับที่ Verify section ของ The comment model เคยรันตอนที่ยังคืนค่าว่างเปล่า:
query PublicCommentsAfterApproval { comments(postId: "<same post id>") { id authorName status }}{ "data": { "comments": [ { "id": "...", "authorName": "Alex", "status": "APPROVED" } ] }}คอมเมนต์อันเดียวกับที่บทเรียนที่แล้วมองไม่เห็น ตอนนี้โผล่ขึ้นมาแล้ว โดยไม่มีการเขียนอะไรนอกจาก moderateComment เลย การเปลี่ยน status แค่ field เดียวคือสิ่งเดียวที่ต่างกันระหว่างการรัน query สาธารณะเดียวกันสองครั้งนี้ ลองยิง moderateComment อีกครั้งด้วย token ของ author (ที่ไม่ใช่ admin) ดู จะ fail ตั้งแต่ก่อน CommentsService.moderate จะได้รัน ยืนยันว่า RolesGuard ทำงานจริงบน mutation จริง
moderateComment เป็น mutation แบบ admin-only ที่เข้มงวด ใช้ GqlAuthGuard บวก RolesGuard กับ @Roles('admin') guard pair เดียวกับที่ Posts resolver ใช้กับ deletePost แล้วพลิก status ของคอมเมนต์ผ่าน CommentsService.moderate ตาม shape findByIdAndUpdate แล้ว null-guard เดียวกับที่ PostsService.publish วางไว้เป็นตัวแรก
เส้นทางที่ admin ใช้ดู comments(status: PENDING) ไม่ต้องเขียนโค้ดใหม่เลย OptionalGqlAuthGuard เติม @CurrentUser() ให้โดยไม่ปฏิเสธการเรียก แล้วเช็ค isAdmin && status ธรรมดาก็ตัดสินว่าจะรับ status ที่ผู้เรียกขอมา หรือแทนที่ด้วย APPROVED แบบเงียบ ๆ ซึ่งต่างจากรูปแบบ throw ตอนขอ DRAFT โดยไม่มีสิทธิ์ของ posts อย่างตั้งใจ เพราะ comments เป็นเส้นทาง render สาธารณะ ไม่ใช่ workflow การเขียน ส่วน spam prevention, rate limiting และ email notification เมื่อมีคอมเมนต์ใหม่ ล้วนเป็นช่องว่างจริงที่คอร์สนี้เอ่ยชื่อไว้แล้วปล่อยให้การใช้งานจริงไปปิดเอง ทุกข้ออยู่ต้นน้ำเหนือ moderateComment ไม่ใช่การแก้ตัว mutation นี้
Next: Frontend Foundations →