The comment model
What we’re building
Section titled “What we’re building”apps/api/src/comments/enums/comment-status.enum.ts (CommentStatus, registered with 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), and apps/api/src/comments/comments.resolver.ts (CommentsResolver, with a public addComment mutation and a moderation-aware comments query). Alongside those, a small apps/api/src/comments/post-comments.resolver.ts (PostCommentsResolver) gives the Post type — from Code-first basics — a new comments field, and CommentsModule gets registered in app.module.ts. This lesson uses the Comment Mongoose schema exactly as Schemas left it, unchanged.
Comment.status from Schemas already defaults to 'pending', which means the schema made this lesson’s central design decision before any resolver code existed: every new comment starts invisible, and something has to actively promote it to 'approved' before a public reader ever sees it. addComment is deliberately a public mutation with no guard at all — commenting is the one write operation in DevBlog that anonymous visitors are allowed to perform, the same way register/login in Auth resolver & GraphQL setup are reachable with no token. But unlike registration, a submitted comment doesn’t become visible on its own; it just enters a queue.
CommentsService.add also has to answer a question PostsService.create never needed to ask: does the post being commented on actually exist, and is it something the public is even allowed to see right now? A comment on a nonexistent post is a 404; a comment attempted on a post that’s still a draft is a 403 — nobody outside its author or an admin knows that post exists yet, so letting a comment attach to it would leak the draft’s existence through a side channel posts(status: DRAFT)’s own guard, from Draft → published, was built specifically to prevent.
The Comment GraphQL type exposes authorEmail — the exact field Schemas already stores, captured at submission time since commenters aren’t authenticated User accounts. That’s a real, deliberate privacy trade-off, made here explicitly rather than by omission: any client that can query comments can read every commenter’s email address back, including anonymous readers on the public site. DevBlog keeps it simple and ships it this way for this course, but restricting authorEmail to an admin-only field — either with a resolver-level check similar to comments’ own status gating below, or by moving it off the public Comment type entirely onto an admin-only shape — is a reasonable follow-up for a real deployment, not a gap this lesson pretends doesn’t exist.
The comments query itself has to serve two audiences from one operation, the same shape Draft → published already solved for posts: a public reader who should only ever see approved comments, and an admin moderating the queue who needs to see pending/rejected ones too. OptionalGqlAuthGuard is the right tool again — it never rejects an anonymous call, it just populates @CurrentUser() when a valid token is present, exactly as Draft → published described it. What comments does with that information is deliberately different from how posts handled status: DRAFT, though — see Pros & cons below.
Finally, Post needs a comments field so a client can fetch a post and its approved thread in one request. The natural place to implement that is a @ResolveField(() => [Comment]) method — but writing it inside PostsResolver (in posts/) would force PostsModule to import CommentsModule for CommentsService. CommentsModule already has to import PostsModule, for CommentsService.add’s post-existence check — importing both ways is exactly the circular-dependency trap. NestJS resolves fields on a type by scanning every @Resolver(() => X) class registered anywhere in the app, not just the ones living in X’s own module, so PostCommentsResolver can live entirely inside comments/, injecting only CommentsService, and contribute a field to Post without PostsModule ever needing to know CommentsModule exists. The dependency stays one-directional: comments depend on posts, posts never depend on comments.
Pros & cons
Section titled “Pros & cons”Pre-moderation (hold every comment until approved) vs. post-moderation (publish immediately, remove bad ones reactively). Comment.status defaulting to 'pending' is pre-moderation: nothing a visitor writes reaches the public thread until an admin explicitly approves it. That guarantees the public view of any post never shows spam, abuse, or off-topic noise, even for a moment — the cost is a genuine commenter’s contribution sits invisible for however long it takes an admin to review it, which can make an otherwise-active post’s thread look emptier than it really is right after a comment surge. Post-moderation flips the trade: every comment appears the instant it’s submitted, so a thread always looks as alive as it actually is, and an admin’s job becomes reactive cleanup instead of a gatekeeping queue — but that means the worst possible comment is live and publicly visible for however long it takes someone to notice and remove it, with zero delay to catch it first. DevBlog takes the pre-moderation side deliberately, the same way Schemas chose it before any resolver existed: a small blog with a single admin can plausibly review a trickle of new comments promptly, and the cost of one visible spam comment is judged higher here than the cost of a short visibility delay for a genuine one. A high-traffic, multi-editor site would likely make the opposite call.
Set it up
Section titled “Set it up”Create 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.',});Create 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;}Create 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;}Create 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(); }}Create 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(); }}addCommenthas no@UseGuards()at all — commenting is open to anyone, the one write path in DevBlog that doesn’t require a token.commentsguards withOptionalGqlAuthGuard, notGqlAuthGuard— it must keep working with zero authentication, since every public post page calls it.effectiveStatusonly ever becomes something other thanAPPROVEDwhen the caller is both signed in androle === 'admin'and actually passed astatusargument; anything short of all three — no token, a non-admin token, or an admin who didn’t ask for anything specific — silently getsAPPROVED, never an error.postIdis a@ResolveField(), not a plain@Field()value — the MongooseCommentschema stores the reference aspost: Types.ObjectId, so this method bridges the field-name and type difference the same wayPostsResolver.authorbridgesPost.author: Types.ObjectIdto a realUserin Posts resolver. Unlikeauthor, there’s no second collection lookup here — just.toString()— because a comment’s own post identifier doesn’t need to become another full object, only a plainID.
Create 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); }}PostCommentsResolveris@Resolver(() => Post), the same class-level targetPostsResolveruses, but it lives incomments/and is registered as a provider inCommentsModule, notPostsModule— Nest’s schema builder collects field resolvers for a type from every registered@Resolver(() => Post)class across the whole app, soPost.commentsandPost.authorcan be implemented in two entirely different modules without either one importing the other.CommentStatus.APPROVED, hard-coded — this field resolver never takes astatusargument and never reads@CurrentUser(); a post’s embedded comment thread is always the public, approved view. An admin wanting the moderation queue uses the root-levelcommentsquery above, not this field.
Update apps/api/src/posts/models/post.model.ts to add the new 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[] is declared here for the same reason author: User already was in Code-first basics — a field has to appear on the @ObjectType() class to exist in the schema at all; PostCommentsResolver.comments above is what actually supplies its value at request time, the declaration and the resolution living in two different files on purpose.
Update apps/api/src/posts/posts.module.ts to 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] is the one change this lesson makes outside comments/ beyond the Post model field above — CommentsModule needs PostsService injectable into CommentsService, and Nest only allows that once the providing module explicitly exports it.
Create 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 imports PostsModule — one direction only. PostsModule never imports CommentsModule back, which is exactly what keeps this pair out of the circular-dependency trap Comments inherits as a general NestJS gotcha the moment two feature modules reference each other’s services.
Update apps/api/src/app.module.ts to register CommentsModule, alongside PostsModule, UsersModule, AuthModule, and 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 {}Verify
Section titled “Verify”npm run start:devUsing the id of the post you published in Draft → published’s Verify section, submit a comment with no Authorization header at all:
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" confirms Comment.status’s Mongoose default from Schemas is doing its job. Now run the public comments query for that same post, still with no Authorization header:
query PublicComments { comments(postId: "<same post id>") { id authorName status }}{ "data": { "comments": [] }}Nothing comes back — the comment just created is real, but it’s still PENDING, and comments forces APPROVED for every caller who isn’t an authenticated admin passing an explicit status. Moderation is where an admin approves it and this same query starts returning it.
CommentsService.add verifies a post exists and is published before creating a comment, always as PENDING — mirroring Schemas’s own default rather than reinventing it. addComment is a public mutation with no guard; comments guards with OptionalGqlAuthGuard and quietly forces APPROVED for anyone who isn’t a status-requesting admin, never throwing. Post.comments is a new field resolved by PostCommentsResolver, a separate @Resolver(() => Post) class living inside CommentsModule specifically so PostsModule never has to import CommentsModule back — comments depend on posts, never the reverse. A comment written just now stays invisible to the public comments query until an admin acts on it.
Next: Moderation →