ข้ามไปยังเนื้อหา

โมเดล Comment

apps/api/src/comments/enums/comment-status.enum.ts (CommentStatus ลงทะเบียนด้วย registerEnumType), apps/api/src/comments/models/comment.model.ts (Comment), apps/api/src/comments/dto/add-comment.input.ts (AddCommentInput), apps/api/src/comments/comments.service.ts (CommentsService.add/findByPost) และ apps/api/src/comments/comments.resolver.ts (CommentsResolver พร้อม mutation addComment แบบสาธารณะ และ query comments ที่รู้เรื่อง moderation) นอกจากนี้ ไฟล์เล็ก ๆ apps/api/src/comments/post-comments.resolver.ts (PostCommentsResolver) จะให้ type Post — จาก Code-first basics — field ใหม่ชื่อ comments และ CommentsModule จะถูกลงทะเบียนใน app.module.ts บทเรียนนี้ใช้ Mongoose schema ของ Comment ตามที่ Schemas วางไว้เป๊ะ ไม่มีการแก้ไขใด ๆ

Comment.status จาก Schemas มีค่าเริ่มต้นเป็น 'pending' อยู่แล้ว ซึ่งหมายความว่า schema ได้ตัดสินใจเรื่องออกแบบหลักของบทเรียนนี้ไปแล้วตั้งแต่ก่อนที่ resolver code จะมีอยู่ด้วยซ้ำ: คอมเมนต์ใหม่ทุกอันเริ่มต้นแบบมองไม่เห็น และต้องมีอะไรมา promote ขึ้นเป็น 'approved' อย่างชัดเจนก่อนผู้อ่านสาธารณะจะเห็น ส่วน addComment ตั้งใจให้เป็น mutation สาธารณะที่ไม่มี guard เลย เพราะการคอมเมนต์คือ write operation เดียวใน DevBlog ที่ผู้เยี่ยมชมแบบไม่ล็อกอินทำได้ เหมือนกับ register/login ใน Auth resolver & GraphQL setup ที่เข้าถึงได้โดยไม่ต้องมี token แต่ต่างจากการลงทะเบียนตรงที่คอมเมนต์ซึ่งส่งเข้ามาไม่ได้ปรากฏต่อสาธารณะเอง แค่เข้าคิวรออยู่เฉย ๆ

CommentsService.add ยังต้องตอบคำถามที่ PostsService.create ไม่เคยต้องถาม: โพสต์ที่กำลังจะคอมเมนต์มีอยู่จริงไหม และตอนนี้สาธารณะมีสิทธิ์เห็นหรือยัง? คอมเมนต์บนโพสต์ที่ไม่มีอยู่จริงตอบ 404 ส่วนคอมเมนต์บนโพสต์ที่ยังเป็น draft ตอบ 403 เพราะนอกจาก author เจ้าของโพสต์กับ admin แล้ว ไม่มีใครรู้ว่าโพสต์นั้นมีอยู่ การยอมให้แนบคอมเมนต์เข้าไปได้เท่ากับทำให้การมีอยู่ของ draft รั่วออกไปทาง side channel นั่นคือสิ่งที่ guard ของ posts(status: DRAFT) ใน Draft → published สร้างมาเพื่อกันพอดี

GraphQL type ของ Comment เปิดเผย authorEmail — field เดียวกับที่ Schemas เก็บไว้อยู่แล้ว บันทึกไว้ตอนส่งคอมเมนต์ เพราะผู้คอมเมนต์ไม่ใช่บัญชี User ที่ยืนยันตัวตนแล้ว นั่นคือ trade-off ด้าน privacy จริง ๆ ที่ตั้งใจทำ เขียนไว้ตรงนี้อย่างชัดเจนแทนที่จะละไว้: client ตัวไหนก็ตามที่ query comments ได้ ก็อ่านอีเมลของผู้คอมเมนต์ทุกคนกลับมาได้ รวมถึงผู้อ่านที่ไม่ล็อกอินบนเว็บสาธารณะด้วย DevBlog เลือกทำแบบง่ายไว้ก่อนสำหรับคอร์สนี้ แต่การจำกัด authorEmail ให้เป็น field เฉพาะ admin — ไม่ว่าจะเช็คที่ระดับ resolver คล้ายกับการคุม status ของ comments ด้านล่าง หรือถอด field นี้ออกจาก Comment type สาธารณะไปอยู่ใน shape เฉพาะ admin — เป็นงานต่อยอดที่สมเหตุสมผลสำหรับการใช้งานจริง ไม่ใช่ช่องว่างที่บทเรียนนี้แกล้งทำเป็นมองไม่เห็น

query comments เองต้องรองรับผู้ใช้สองกลุ่มจาก operation เดียว รูปแบบเดียวกับที่ Draft → published แก้ไว้แล้วสำหรับ posts: ผู้อ่านสาธารณะที่ควรเห็นแค่คอมเมนต์ approved เท่านั้น กับ admin ที่กำลังตรวจสอบคิวซึ่งต้องเห็น pending/rejected ด้วย OptionalGqlAuthGuard เป็นเครื่องมือที่ถูกต้องอีกครั้ง เพราะไม่ปฏิเสธการเรียกแบบไม่ล็อกอิน แค่เติม @CurrentUser() ให้เมื่อมี token ที่ใช้ได้จริง ตรงตามที่ Draft → published อธิบายไว้ แต่สิ่งที่ comments ทำกับข้อมูลนั้น ตั้งใจให้ต่างจากวิธีที่ posts จัดการ status: DRAFT ดูรายละเอียดที่หัวข้อข้อดีข้อเสียด้านล่าง

สุดท้าย Post ต้องมี field comments เพื่อให้ client ดึงโพสต์พร้อมเธรดที่ approved แล้วได้ใน request เดียว จุดที่ทำได้เป็นธรรมชาติที่สุดคือ method @ResolveField(() => [Comment]) แต่ถ้าเขียนไว้ใน PostsResolver (ที่อยู่ใน posts/) จะบังคับให้ PostsModule ต้อง import CommentsModule เพื่อเอา CommentsService มาใช้ ขณะที่ CommentsModule เองก็ต้อง import PostsModule อยู่แล้วเพื่อเช็คว่าโพสต์มีจริงใน CommentsService.add การ import ไขว้กันแบบนี้คือกับดัก circular dependency พอดี

โชคดีที่ NestJS resolve field ของ type ด้วยการสแกนทุกคลาส @Resolver(() => X) ที่ลงทะเบียนไว้ทั่วทั้งแอป ไม่ใช่แค่คลาสที่อยู่ใน module ของ X PostCommentsResolver จึงอยู่ใน comments/ ได้ทั้งก้อน inject แค่ CommentsService แล้วเพิ่ม field ให้ Post โดยที่ PostsModule ไม่ต้องรู้เลยว่ามี CommentsModule อยู่ ความสัมพันธ์จึงเป็นทางเดียวตลอด: comments พึ่ง posts ส่วน posts ไม่พึ่ง comments

Pre-moderation (กักคอมเมนต์ทุกอันไว้จนกว่าจะ approve) เทียบกับ post-moderation (เผยแพร่ทันที แล้วลบอันแย่ ๆ ออกทีหลัง) Comment.status ที่ default เป็น 'pending' คือ pre-moderation ไม่มีอะไรที่ผู้เยี่ยมชมเขียนไปถึงเธรดสาธารณะจนกว่า admin จะกด approve นั่นการันตีว่าหน้าสาธารณะของโพสต์ไหนก็ไม่มีวันโชว์ spam การล่วงละเมิด หรือ noise ที่ไม่เกี่ยว แม้แต่วินาทีเดียว ต้นทุนคือคอมเมนต์จริงจากคนจริงจะยังไม่ขึ้นจนกว่า admin จะรีวิวเสร็จ ทำให้เธรดของโพสต์ที่ active จริง ๆ อาจดูว่างกว่าความเป็นจริงในช่วงที่คอมเมนต์เข้ามาถี่ ๆ

post-moderation กลับด้าน trade-off นี้ ทุกคอมเมนต์ขึ้นทันทีที่ส่ง เธรดจึงดู active ตรงตามความจริงเสมอ และงานของ admin เปลี่ยนจากเฝ้าประตูเป็นตามเก็บกวาดทีหลัง แต่ก็แปลว่าคอมเมนต์ที่แย่ที่สุดเท่าที่จะเป็นไปได้จะ live ให้สาธารณะเห็นอยู่นานจนกว่าจะมีคนสังเกตแล้วลบ โดยไม่มีจังหวะหน่วงให้จับได้ก่อนเลย

DevBlog เลือกฝั่ง pre-moderation อย่างตั้งใจ ตามที่ Schemas ตัดสินไว้ตั้งแต่ก่อนมี resolver ด้วยซ้ำ เพราะบล็อกเล็ก ๆ ที่มี admin คนเดียวรีวิวคอมเมนต์ใหม่ที่ทยอยเข้ามาได้ทันอยู่แล้ว และประเมินว่าต้นทุนของ spam ที่หลุดออกสาธารณะแม้อันเดียว สูงกว่าต้นทุนของการหน่วงคอมเมนต์จริงไว้ครู่หนึ่ง ส่วนเว็บที่ทราฟฟิกสูงและมีบรรณาธิการหลายคนน่าจะเลือกตรงข้าม

สร้าง apps/api/src/comments/enums/comment-status.enum.ts:

import { registerEnumType } from '@nestjs/graphql';
export enum CommentStatus {
PENDING = 'pending',
APPROVED = 'approved',
REJECTED = 'rejected',
}
registerEnumType(CommentStatus, {
name: 'CommentStatus',
description: 'Moderation status of a comment — mirrors Comment.status from Data Modeling.',
});

สร้าง apps/api/src/comments/models/comment.model.ts:

import { Field, ID, ObjectType } from '@nestjs/graphql';
import { CommentStatus } from '../enums/comment-status.enum';
@ObjectType()
export class Comment {
@Field(() => ID)
id: string;
@Field(() => ID)
postId: string;
@Field()
authorName: string;
@Field()
authorEmail: string;
@Field()
body: string;
@Field(() => CommentStatus)
status: CommentStatus;
@Field()
createdAt: Date;
}

สร้าง apps/api/src/comments/dto/add-comment.input.ts:

import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsString, MinLength } from 'class-validator';
@InputType()
export class AddCommentInput {
@Field()
@IsString()
@MinLength(2)
authorName: string;
@Field()
@IsEmail()
authorEmail: string;
@Field()
@IsString()
@MinLength(5)
body: string;
}

สร้าง apps/api/src/comments/comments.service.ts:

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();
}
}

สร้าง apps/api/src/comments/comments.resolver.ts:

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 { OptionalGqlAuthGuard } from '../auth/optional-gql-auth.guard';
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);
}
@ResolveField(() => ID)
postId(@Parent() comment: CommentDocument): string {
return comment.post.toString();
}
}
  • addComment ไม่มี @UseGuards() เลย — การคอมเมนต์เปิดให้ทุกคน เป็นเส้นทางเขียนเดียวใน DevBlog ที่ไม่ต้องใช้ token
  • comments guard ด้วย OptionalGqlAuthGuard ไม่ใช่ GqlAuthGuard เพราะ query นี้ต้องทำงานได้แม้ไม่มีการยืนยันตัวตน ทุกหน้าโพสต์สาธารณะเรียกใช้ ค่า effectiveStatus จะเป็นอย่างอื่นนอกจาก APPROVED ก็ต่อเมื่อผู้เรียกล็อกอินอยู่ และ role === 'admin' และ ส่ง argument status มาจริง ขาดข้อใดข้อหนึ่งไป — ไม่มี token, token ของคนที่ไม่ใช่ admin, หรือ admin ที่ไม่ได้ขอเจาะจง — ก็ได้ APPROVED แบบเงียบ ๆ ไม่มี error
  • postId เป็น @ResolveField() ไม่ใช่ค่า @Field() ธรรมดา — Mongoose schema ของ Comment เก็บ reference เป็น post: Types.ObjectId ดังนั้น method นี้จึงเชื่อมความต่างของชื่อ field และ type แบบเดียวกับที่ PostsResolver.author เชื่อม Post.author: Types.ObjectId ไปเป็น User จริง ๆ ใน Posts resolver ต่างจาก author ตรงที่ไม่มีการ lookup collection ที่สองที่นี่ — แค่ .toString() — เพราะ identifier ของโพสต์ในคอมเมนต์ไม่ต้องกลายเป็น object เต็ม ๆ อีกอัน แค่ ID ธรรมดาก็พอ

สร้าง apps/api/src/comments/post-comments.resolver.ts:

import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Post } from '../posts/models/post.model';
import { PostDocument } from '../posts/schemas/post.schema';
import { Comment } from './models/comment.model';
import { CommentStatus } from './enums/comment-status.enum';
import { CommentDocument } from './schemas/comment.schema';
import { CommentsService } from './comments.service';
@Resolver(() => Post)
export class PostCommentsResolver {
constructor(private readonly commentsService: CommentsService) {}
@ResolveField(() => [Comment])
comments(@Parent() post: PostDocument): Promise<CommentDocument[]> {
return this.commentsService.findByPost(post.id, CommentStatus.APPROVED);
}
}
  • PostCommentsResolver คือ @Resolver(() => Post) เป้าหมายระดับคลาสเดียวกับที่ PostsResolver ใช้ แต่ไฟล์อยู่ใน comments/ และลงทะเบียนเป็น provider ใน CommentsModule ไม่ใช่ PostsModule schema builder ของ Nest รวบรวม field resolver ของ type จากทุกคลาส @Resolver(() => Post) ที่ลงทะเบียนไว้ทั่วแอป Post.comments กับ Post.author จึง implement อยู่คนละ module ได้ โดยไม่มี module ไหนต้อง import อีก module เลย
  • CommentStatus.APPROVED ฝังไว้ตายตัว — field resolver นี้ไม่รับ argument status เลยและไม่อ่าน @CurrentUser() เลย เธรดคอมเมนต์ที่ฝังอยู่ในโพสต์คือมุมมองสาธารณะที่ approved แล้วเสมอ admin ที่ต้องการคิว moderation ใช้ query comments ระดับ root ด้านบน ไม่ใช่ field นี้

อัปเดต apps/api/src/posts/models/post.model.ts เพื่อเพิ่ม field ใหม่:

import { Field, ID, ObjectType } from '@nestjs/graphql';
import { User } from '../../users/models/user.model';
import { PostStatus } from '../enums/post-status.enum';
import { Comment } from '../../comments/models/comment.model';
@ObjectType()
export class Post {
@Field(() => ID)
id: string;
@Field()
title: string;
@Field()
slug: string;
@Field()
body: string;
@Field({ nullable: true })
excerpt?: string;
@Field({ nullable: true })
coverImage?: string;
@Field(() => PostStatus)
status: PostStatus;
@Field(() => [String])
tags: string[];
@Field({ nullable: true })
publishedAt?: Date;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => User)
author: User;
@Field(() => [Comment])
comments: Comment[];
}

comments: Comment[] ถูกประกาศไว้ตรงนี้ด้วยเหตุผลเดียวกับที่ author: User เคยถูกประกาศไว้ใน Code-first basics — field ต้องปรากฏบนคลาส @ObjectType() ก่อนถึงจะมีอยู่ใน schema ได้เลย PostCommentsResolver.comments ด้านบนคือตัวที่ป้อนค่าจริงตอน request ทำงาน การประกาศกับการ resolve อยู่คนละไฟล์กันโดยตั้งใจ

อัปเดต apps/api/src/posts/posts.module.ts เพื่อ export PostsService:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Post, PostSchema } from './schemas/post.schema';
import { PostsService } from './posts.service';
import { PostsResolver } from './posts.resolver';
import { UsersModule } from '../users/users.module';
import { CommonModule } from '../common/common.module';
@Module({
imports: [
MongooseModule.forFeature([{ name: Post.name, schema: PostSchema }]),
UsersModule,
CommonModule,
],
providers: [PostsService, PostsResolver],
exports: [PostsService],
})
export class PostsModule {}

exports: [PostsService] คือการเปลี่ยนแปลงเดียวที่บทเรียนนี้ทำนอก comments/ นอกเหนือจาก field ใน Post model ด้านบน เพราะ CommentsModule ต้อง inject PostsService เข้า CommentsService และ Nest จะยอมก็ต่อเมื่อ module ที่ provide service นั้น export ออกมาอย่างชัดเจน

สร้าง apps/api/src/comments/comments.module.ts:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Comment, CommentSchema } from './schemas/comment.schema';
import { CommentsService } from './comments.service';
import { CommentsResolver } from './comments.resolver';
import { PostCommentsResolver } from './post-comments.resolver';
import { PostsModule } from '../posts/posts.module';
@Module({
imports: [
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
PostsModule,
],
providers: [CommentsService, CommentsResolver, PostCommentsResolver],
})
export class CommentsModule {}

CommentsModule import PostsModule — ทางเดียวเท่านั้น PostsModule ไม่ import CommentsModule กลับเลย นั่นคือสิ่งที่ทำให้คู่นี้ไม่ตกกับดัก circular-dependency ที่ Comments สืบทอดมาเป็นข้อควรระวังทั่วไปของ NestJS ทันทีที่สอง feature module อ้างอิง service ของกันและกัน

อัปเดต apps/api/src/app.module.ts เพื่อลงทะเบียน CommentsModule คู่กับ PostsModule, UsersModule, AuthModule และ TagsModule:

import { join } from 'node:path';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
import { Logger } from '@nestjs/common';
import { Connection } from 'mongoose';
import * as Joi from 'joi';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PostsModule } from './posts/posts.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { TagsModule } from './tags/tags.module';
import { CommentsModule } from './comments/comments.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '../../.env',
validationSchema: Joi.object({
MONGODB_URI: Joi.string().uri().required(),
JWT_SECRET: Joi.string().min(10).required(),
API_PORT: Joi.number().port().default(4000),
WEB_ORIGIN: Joi.string().uri().required(),
}),
validationOptions: {
allowUnknown: true,
abortEarly: false,
},
}),
MongooseModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
uri: configService.getOrThrow<string>('MONGODB_URI'),
onConnectionCreate: (connection: Connection) => {
connection.on('connected', () =>
new Logger('MongooseModule').log('MongoDB connected'),
);
return connection;
},
}),
}),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
playground: false,
plugins: [ApolloServerPluginLandingPageLocalDefault()],
context: ({ req }) => ({ req }),
}),
PostsModule,
UsersModule,
AuthModule,
TagsModule,
CommentsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Terminal window
npm run start:dev

ใช้ id ของโพสต์ที่คุณ publish ไว้ใน Verify section ของ Draft → published ส่งคอมเมนต์โดยไม่มี header Authorization เลย:

mutation AddComment {
addComment(
postId: "<a published post id>"
input: {
authorName: "Alex"
authorEmail: "alex@example.com"
body: "Great post, thanks for writing this up!"
}
) {
id
authorName
status
}
}
{
"data": {
"addComment": {
"id": "...",
"authorName": "Alex",
"status": "PENDING"
}
}
}

status: "PENDING" ยืนยันว่าค่าเริ่มต้นของ Mongoose ของ Comment.status จาก Schemas ทำงานถูกต้อง ตอนนี้รัน query comments สาธารณะสำหรับโพสต์เดียวกัน ยังไม่มี header Authorization เหมือนเดิม:

query PublicComments {
comments(postId: "<same post id>") {
id
authorName
status
}
}
{
"data": {
"comments": []
}
}

ไม่มีอะไรกลับมาเลย คอมเมนต์ที่เพิ่งสร้างมีอยู่จริง แต่ยังเป็น PENDING ส่วน comments บังคับเป็น APPROVED เสมอสำหรับผู้เรียกทุกคน ยกเว้น admin ที่ยืนยันตัวตนแล้วและส่ง status มาเจาะจง Moderation คือบทที่ admin เข้ามา approve แล้ว query เดียวกันนี้จะเริ่มคืนคอมเมนต์นั้นกลับมา

CommentsService.add เช็คก่อนว่าโพสต์มีอยู่จริงและ published แล้ว จึงค่อยสร้างคอมเมนต์ โดยลงเป็นสถานะ PENDING เสมอ ซึ่งสะท้อนค่า default ของ Schemas ไม่ได้คิดกฎใหม่ ตัว addComment เป็น mutation สาธารณะที่ไม่มี guard ส่วน comments guard ด้วย OptionalGqlAuthGuard แล้วบังคับ APPROVED แบบเงียบ ๆ กับทุกคนที่ไม่ใช่ admin ซึ่งขอ status เจาะจง โดยไม่ throw เลยสักกรณี

Post.comments เป็น field ใหม่ที่ resolve ด้วย PostCommentsResolver คลาส @Resolver(() => Post) แยกต่างหากที่วางไว้ใน CommentsModule โดยเฉพาะ เพื่อไม่ให้ PostsModule ต้อง import CommentsModule กลับ ทิศทางการพึ่งพาจึงเป็น comments → posts ทางเดียวตลอด ส่วนคอมเมนต์ที่เพิ่งเขียนไปยังมองไม่เห็นจาก query comments สาธารณะ จนกว่า admin จะเข้ามาจัดการ

Next: Moderation →