Project
social blogging platform
A full stack blogging platform with publishing, social features, private chat, and live notifications, built with React and NestJS
Social Blogging Platform
This project is a full stack social blogging application built with React on the client and NestJS on the backend, both in TypeScript. Users can create accounts, publish posts, follow other accounts, comment and reply, react to content, save bookmarks, subscribe to authors, and exchange private messages. The application also supports live chat and notifications, account verification, Google sign-in, and two-factor authentication.
The backend is one NestJS application divided into modules. PostgreSQL stores the main application data. Redis caches responses. RabbitMQ handles queued mail, SMS, notifications, and logs. Elasticsearch stores request logs. Cloudinary stores uploaded images, while Mailgun and Twilio deliver email and SMS. Google OAuth2 provides another way to register and sign in. Docker Compose starts the application and its supporting services for local development.
Publishing a post shows how the modules work together: the post is stored in PostgreSQL, subscriber mail and notification jobs enter RabbitMQ, and connected clients receive live updates through Socket.IO.
The backend and client are in separate GitHub repositories.
Web client
The web client is a separate React and TypeScript application for this API. It provides pages for browsing posts and tags, reading public posts, viewing profiles, registering and signing in, writing and editing posts, and managing account settings. Signed-in users also have a dashboard for their posts and bookmarks, an inbox for private conversations, and an area for notifications. The client uses React Router for pages and Redux Toolkit for application state.
HTTP requests go through a shared Axios helper that prefixes routes with /api/. It uses REACT_APP_HOST when a backend host is configured, or a relative path when the client and API share a host:
export const BASE_URL = process.env.REACT_APP_HOST
? process.env.REACT_APP_HOST + "/api/"
: "/api/";
The client calls the API’s local and Google authentication, post, account, chat, and notification endpoints. For live updates, it connects to the backend’s /chats and /notifications Socket.IO namespaces. The chat component joins a room and listens for new messages; the notification socket listens for new account notifications. The client has its own Docker Compose file for building the React app and serving it through Nginx.
Backend application structure
NestJS modules divide the API by responsibility. Resource modules handle accounts, posts, comments, follows, bookmarks, chats, messages, and notifications. Other modules provide shared capabilities such as authentication, uploads, caching, queues, and logging. TypeORM maps the main entities to PostgreSQL.
The application uses guards for authentication and authorization, DTOs and validation pipes for request data, and interceptors for work around controller actions. For example, creating a post can trigger subscriber notifications, while reading a public post can pass through a cache interceptor. Interfaces and dependency injection connect these classes across modules.
Swagger exposes the API documentation at /api. The development environment also includes PgAdmin for PostgreSQL, RedisInsight for Redis, a RabbitMQ management interface, and an Elasticsearch UI.
The application stays in one process, but each feature has its own controller, service, DTOs, and entity where needed. PostsService, for example, uses a repository, an upload service, a tag service, and a URL service to create a post.
Some features need services from one another. The accounts and two-factor modules use NestJS forwardRef for their mutual dependency. A few interceptors obtain a service through ModuleRef when an action crosses modules, such as checking a profile’s follow state or signing a new token after an account update.
PostgreSQL is the source of truth for accounts, posts, follows, chats, messages, and notifications. Redis is used for responses that can be reconstructed from that data. RabbitMQ carries tasks to consumers within the application. Socket.IO handles live delivery to connected clients. This division matters because a stored message and a live event serve different purposes: the database keeps the conversation, while the gateway lets a connected client see a new message immediately.
Integrations and infrastructure
The project uses several services, each with a specific role:
- PostgreSQL and TypeORM store accounts, posts, comments, messages, and their relationships.
- Redis stores cached JSON responses, including responses scoped to the signed-in account.
- RabbitMQ carries queued email, SMS, notification, and logging work.
- Elasticsearch stores request and error logs for inspection.
- Cloudinary stores uploaded profile and post images.
- Mailgun and Twilio send verification codes and other email or SMS notifications.
- Google OAuth2 supports Google account registration and authentication.
- Socket.IO delivers live chat messages and account notifications.
Docker Compose brings up the API alongside PostgreSQL, Redis, RabbitMQ, Elasticsearch, and their inspection tools. Cloudinary, Mailgun, and Twilio require provider credentials. Google authentication receives an access token from the client and uses it to request the user’s Google profile.
The provider adapters have their own behavior. Mailgun sends ordinary HTML messages and template messages. Twilio sends SMS through a configured Messaging Service SID. Cloudinary returns secure URLs for uploaded images and applies a separate rounded transformation for profile pictures. The mail and SMS adapters translate invalid contact details into bad-request errors and provider failures into bad-gateway errors, which the API’s exception filters can handle.
The integrations meet in application flows. When someone registers with an email address or phone number, the API creates a verification code, sends it through Mailgun or Twilio, and keeps the registration data in a temporary account until the code is confirmed. When someone publishes a post, an interceptor places subscriber mail and notification work on RabbitMQ queues. Consumers look up the author’s subscribers, send email through Mailgun, and push live notifications through Socket.IO.
Data model
The main entities are connected through TypeORM relationships. An Account has a UUID, unique username, credentials, role, and profile fields. It owns posts and comments, can belong to chats, and has relationships for follows and two-factor authentication. The entity defines its post relationship as:
@Entity()
export class Account {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
username: string;
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}
These are selected fields; the linked source contains the rest of the entity. A Post points back to its author and connects to comments, bookmarks, reactions, and tags. It also stores its title, content, URL, optional title image, publication status, and timestamps. Its author and tag relationships are defined as:
@ManyToOne(() => Account, (account) => account.posts, {
onDelete: 'CASCADE',
})
author: Account;
@ManyToMany(() => Tag, (tag) => tag.posts)
@JoinTable()
tags: Tag[];
A PostComment belongs to a post and author. Its @Tree('closure-table'), @TreeParent(), and @TreeChildren() decorators represent replies within the same entity. A Follow joins the follower and followed accounts and stores email and account-notification preferences in a JSONB subscriptions column.
A Chat relates its members and messages. Each ChatMessage stores its chat, sender, content, read state, and timestamps. An AccountNotification stores the sender, recipient, action, related post or comment when applicable, and a seen flag for unread counts. The remaining social records have their own entities: Tag, Bookmark, PostExpression, and CommentExpression.
These relationships support queries for an author’s public posts, comment and reaction counts, a user’s bookmarks, and the latest message in a chat.
Accounts and authorization
The API supports local registration and Google authentication. Local users can register through an email address or mobile phone number. Registration starts by sending a verification code and storing a temporary account. After the code is checked, the temporary data becomes an account and the user receives a login token.
The TemporaryAccount is a separate database entity with the proposed username, display name, password, and email address or phone number. The registration endpoint creates a verification code and sends it to the selected contact method. The response includes a URL containing a token for the verification step. The user submits the code to that URL; the API checks that it matches the token and registration process, creates the permanent account, and deletes the temporary record. The code delivery services schedule deletion of unused registration codes after five minutes.
Local login accepts a username, email address, or phone number with a password. The password manager checks the supplied password against the stored hash. After successful authentication, the application returns a JWT. For Google authentication, the client provides a Google access token. The API calls Google’s user information endpoint, uses the returned email to find or create the account, and then issues its own login token. The Google and local flows use separate controllers and services because they have different credentials and verification steps.
Passwords are hashed and compared through a PasswordManagerService backed by BCrypt, with a configured salt-round value of 10. The login token contains the account ID, username, display name, profile image, and role. Its expiry is configured through the environment. JWT guards protect account actions, while an optional JWT guard lets public post and profile requests include client-specific information when a user is signed in.
Users can enable two-factor authentication through email or SMS. Its settings are stored in a TwoFactorAuth entity. When a user with two-factor authentication logs in, the application starts the additional verification step before completing the login flow. Mailgun and Twilio provide the delivery channels, so those account flows depend on external API credentials.
Two-factor authentication has its own entity, controllers, and service. A local account can use email or mobile phone verification. The Google account login flow also checks whether a phone factor is enabled. Disabling a factor requires a password check and a verification code sent to the active channel. Verification codes and two-factor settings have separate modules.
Account controllers also support changing a username or password and adding or removing email and phone credentials through verification steps. The local account flow requires at least one contact method to remain available when removing an email address or phone number. A profile endpoint returns an account by username with follower and following counts. The owner can update the display name or profile image. Cloudinary applies a square, rounded transformation to profile uploads, and the response includes a new JWT when token-bearing profile data changes.
The account model has user, moderator, and administrator roles. CASL defines what each role may manage. For normal users, permissions are tied to ownership of resources such as posts, comments, bookmarks, and account notifications. A guard loads the resource and checks the requested action before the controller performs it.
The authorization guard derives the requested action from the HTTP method: reading for GET, updating for PUT and PATCH, and deleting for DELETE. It loads the resource by ID, builds the user’s CASL ability from the JWT payload, and only lets the request proceed if that ability permits the action on that resource. Administrators can manage all supported subjects. Moderators can manage posts, comments, and tags. Regular users can manage resources that belong to them.
The guard connects the requested HTTP action to the loaded object, so the permission check is about a particular post or comment rather than only a route name:
const action = this.detectActionType(req.method);
const subject = await this.service.getOneByID(req.params.id);
const ability: any = this.caslAbilityFactory.createForClient({
client: req.user,
});
if (ability.can(action, subject)) {
req.data = subject;
return true;
}
return false;
Posts and social features
Posts are the center of the data model. Each post belongs to an author and can have tags, comments, reactions, and bookmarks. A post can be published or kept unpublished. Images are uploaded through Cloudinary, and posts have a URL generated from their title.
Creating a post accepts a title, content, tags, and an optional title image. The post service turns the title into a unique URL, finds or creates each tag, uploads the image if one was provided, and saves the result with its author. A published post appears in public queries. An unpublished post remains available in the author’s own post list. The author can change the published state, edit the content and tags, replace the title image, or delete the post.
The URL service uses slugify to convert a title to lowercase URL text and appends a generated short ID so posts with similar titles can still have distinct addresses. Tag creation is also handled during post creation: the service checks whether a tag name exists before creating it and attaching it to the post.
The public post listing joins author and tag data and calculates comment and like counts. The author’s own listing also includes whether each post is published and counts for comments and bookmarks. The URL lookup returns only a published post. These separate queries provide the fields needed by different views without treating every post response as the same object.
The surrounding modules provide the social behavior: accounts can follow one another, comment on posts, reply to comments, react to posts and comments, and bookmark posts. Follow relationships also store email and notification subscription settings. Publishing a post can create work for follower notifications and email delivery.
Comments can belong directly to a post or to another comment. The API can fetch top-level comments for a public post and fetch replies for one comment. It also calculates counts for reactions and replies. Follow relationships connect the follower and the followed account, and the subscription settings sit on that relationship. This lets the application identify the followers of an author when a new post is published.
Publishing has a specific cross-module flow. After the post service saves a published post, a NestJS interceptor sends two messages to RabbitMQ: one for subscriber email and one for subscriber notifications. The consumers load the author’s subscribers. The email consumer sends a message through the mail service, while the notification consumer attempts to push a live event through the notification gateway. An unpublished post does not trigger those two messages.
The publishing interceptor shows where the two queued tasks begin:
if (value.data.published) {
const notificationObject = {
subject: 'published a post.',
post: value.data,
};
this.mailsWorker.produceSubscriberMails(notificationObject);
this.notificationsWorker.produceSubcriberNotifications(
notificationObject,
);
}
Chats and live notifications
Chats are between two accounts. Creating a chat saves the member relationship and its first message. The service rejects a request to chat with yourself and checks whether the same pair already has a chat. Messages are then created through an authenticated HTTP endpoint and stored in PostgreSQL.
After a message is saved, an interceptor passes it to the chat gateway. A connected client joins a Socket.IO room for the chat ID, and the gateway emits the new message to that room. The account chat list includes the most recent message, so the message service also updates cached chat entries for both members when it saves a message. The persisted message and the socket event are two parts of the same flow: one keeps history, and the other delivers the update to connected clients.
The gateway uses the chat ID as the Socket.IO room name. Clients join that room, and the gateway emits new messages only to sockets in it:
handleChat(@MessageBody() chatID: string, @ConnectedSocket() client: Socket) {
client.join(chatID);
client.emit('joined', chatID);
}
sendMessageToChat(chatID: string, message: NewMessageDto) {
this.server.to(chatID).emit('message', message);
}
Account notifications cover actions such as following an account, commenting or replying, and reacting to a post or comment. A notification is stored with its sender, recipient, action, related object, and seen state. The API can return a user’s notifications and unread count. Notification work can pass through RabbitMQ; a consumer retrieves the stored record, updates the cached notification list, and asks the Socket.IO gateway to push it when the relevant clients are connected. A user can therefore retrieve stored notifications even when a live event was not received.
Caching, queues, and logging
Redis stores cached JSON responses. Separate interceptors handle public responses and responses that belong to a signed-in account. The personal cache key includes the account ID, so two users do not share the same cached response. The cache layer also has methods for updating or removing cached data when related resources change.
Both interceptors follow a cache-aside pattern. They check Redis before calling the controller. If a value is present, they return it. Otherwise, they let the controller run and save its response with an expiration time. A public key is based on the request URL. A personal key adds the authenticated account ID. The default cache TTL is 60 seconds, while individual routes can set a different value; the client chat list, for example, uses 30 seconds.
The personal cache interceptor adds the account ID before reading Redis:
const client: JwtPayload = context.switchToHttp().getRequest().user;
const key = this.extractKey(context) + '/' + client.sub;
const cached = await this.cacheJsonService.get(key);
if (cached) {
return of(cached);
}
return next.handle().pipe(
map((value) => {
const ttl = this.extractTTL(context);
this.cacheJsonService.save({ key, data: value, ttl });
return value;
}),
);
The cache service also supports updating an object, changing selected fields, inserting or removing an item from a cached array, and deleting a key. Account updates, chat creation, new messages, and notification changes use these operations. A PostgreSQL write can make an earlier response stale unless the relevant cache entry is changed or expires.
RabbitMQ carries background work for mail, SMS, notifications, and logs. Producers send messages to queues, and consumers handle the delivery or follow-up work. For example, publishing a post queues subscriber mail and live notifications. Registration verification codes, on the other hand, are sent directly through the email or SMS service as part of that account flow.
The queue module has workers for mail, SMS, notifications, and logging. The mail worker has queues for subscriber messages, registration messages, and two-factor messages. Notification workers handle stored account notifications and new-post subscriber events. Each consumer declares its queue, receives a message, calls the relevant application service, and acknowledges the message. Queue properties differ by task: subscriber notification queues are durable, while registration mail and SMS queues have a five-minute message TTL and the two-factor mail queue has a two-minute TTL.
RabbitMQ is used as a broker inside the monolithic API rather than as communication between separate microservices. It lets a request create work that a consumer handles separately from the controller response. The external provider remains behind the mail or SMS service, so the worker does not need to know Mailgun or Twilio API details.
The logging interceptor records request details such as the method, URL, client ID, and response time. It sends that data through a RabbitMQ queue, and a consumer writes it to Elasticsearch.
Successful requests pass through the logging interceptor. Exception filters handle error responses and also produce log data. The logging service places ordinary requests in an info index and uses separate indices for some application and HTTP errors. The log record includes the request method and URL, timing information, and either the authenticated client’s ID or guest. These records can be inspected through the Elasticsearch UI.
The logging service selects an Elasticsearch index from the log record:
if (!data.exception) {
await this.elastic.index({
index: 'info',
document: data,
});
} else if (data.exception && !data.exception.status) {
await this.elastic.index({
index: 'app_errors',
document: { ...data, exception: JSON.stringify(data.exception) },
});
} else if (data.exception.status >= 400 && data.exception.status <= 499) {
await this.elastic.index({
index: 'http_errors',
document: { ...data, exception: JSON.stringify(data.exception) },
});
} else if (data.exception.status == 502) {
await this.elastic.index({
index: 'bad_gateway',
document: { ...data, exception: JSON.stringify(data.exception) },
});
}
The service stores normal requests, application errors, client HTTP errors, and bad gateway errors in separate indices.
Object-oriented design
The application uses responsibility boundaries, interfaces, dependency inversion, factories, inheritance, and polymorphism. NestJS dependency injection connects the classes, while TypeScript interfaces describe the behavior expected from them.
Single responsibilities and composition
Controllers accept requests and shape responses. Services contain the application operations. Repositories handle persistence. Other classes take care of delivery, uploads, URL creation, caching, and authorization. For example, PostsService coordinates a TypeORM repository, UploadsService, TagsService, and UrlManagementService when it creates a post. It does not contain Cloudinary API calls or the code that turns a title into a URL.
This applies the single responsibility principle to the main layers: a controller handles HTTP, a service handles an application operation, and a provider integration handles the details of an external API. The post service coordinates several collaborators, each responsible for its own operation.
Classes also hide smaller implementation steps. The post service keeps tag lookup and creation in a private method. The Cloudinary service keeps file conversion and the underlying upload call private, exposing operations for a normal image and a transformed profile image. This is encapsulation at the service boundary: a caller works with public operations without depending on the internal upload steps.
Authentication also has separate local and Google services. Local authentication validates a password against a stored hash; Google authentication checks an access token with Google’s user information endpoint. Both produce the application’s own login result through separate account lookup and validation steps.
The same structure appears in verification. VerificationCodesService stores and checks codes. EmailNotificationService and MobilePhoneNotificationService choose how to deliver them. TasksService schedules deletion of unused codes. A controller can coordinate a registration flow without implementing all of those jobs itself.
Small interfaces
The CRUD contracts are split by operation. A service implements the capabilities it supports rather than inheriting one large interface with methods it does not need:
export interface ICreateService {
create(data: any): Promise<any>;
}
export interface IFindService {
getOneByID(id: string): Promise<any>;
getAll(): Promise<any[]>;
}
export interface IUpdateService {
update(subject: any, updateDto: any): Promise<any>;
}
export interface IDeleteService {
delete(subject: any): Promise<string>;
}
PostsService implements all four contracts. ChatsService only implements creation and lookup. NotificationsService implements lookup, update, and deletion. This is the interface segregation principle applied to the service layer. The contracts are broad in their parameter types, but the separation still records which operations each service promises to provide.
Dependency inversion
The upload and mail modules depend on interfaces instead of hard-coding an external provider into the application-facing service. UploadsService depends on IUploadImageService, while the NestJS module binds that token to CloudinaryService:
export interface IUploadImageService {
uploadImage(image: Express.Multer.File): Promise<string>;
uploadProfileImage(image: Express.Multer.File): Promise<string>;
}
export const IUploadImageService = Symbol('IUploadImageService');
@Module({
imports: [CloudinaryModule],
providers: [
UploadsService,
{ provide: IUploadImageService, useClass: CloudinaryService },
],
exports: [UploadsService],
})
export class UploadsModule {}
@Injectable()
export class UploadsService {
constructor(
@Inject(IUploadImageService)
private uploadImageService: IUploadImageService,
) {}
async uploadProfileImage(file: Express.Multer.File): Promise<string> {
return this.uploadImageService.uploadProfileImage(file);
}
async uploadImage(file: Express.Multer.File): Promise<string> {
return this.uploadImageService.uploadImage(file);
}
}
MailsService uses the same idea with an IMailSenderService token bound to MailgunService. The SMS and hash manager modules also hide their providers behind injected contracts. The symbol tokens matter because TypeScript interfaces disappear at runtime; NestJS needs a real token to resolve a provider. A different upload or mail provider could be connected through the module binding without changing the application-facing service. This is where dependency inversion also supports the open/closed principle. The same boundary lets tests replace a provider without contacting the real external API.
Factories and polymorphism
Registration and two-factor authentication can deliver a code by email or phone. NotificationFactory selects the appropriate implementation and returns the shared INotificationService interface:
createNotification(by: NotificationBy): INotificationService {
switch (by) {
case 'mobile_phone':
return this.mobilePhoneNotificationService;
case 'email':
return this.emailNotificationService;
default:
throw new Error('Illegal argument exception');
}
}
The caller asks the returned object to perform notifyForRegister or notifyForTFA. Email and phone implementations follow the same contract but use different delivery services. This is a concrete use of polymorphism: the controller works with a notification behavior while the selected object determines how the code reaches the user.
There is a second factory for authentication. AuthFactory selects LocalAuthService or GoogleAuthService from the account’s registration type. The two-factor login endpoint uses that factory after a verification code is accepted, then calls the selected service’s shared login behavior to create the application’s JWT. This makes the registration type an explicit choice in the authentication flow instead of scattering local-versus-Google checks through the controller.
Shared behavior through inheritance
The local and Google authentication services extend BaseAuthService. The base class creates the JWT response and requires each child class to provide its own validateAccount method. This keeps token creation in one place while allowing two different credential checks. The cache interceptors use a similar base class for cache key and TTL extraction, then specialize how the key is built for public or personal responses.
The base authentication contract expresses that split directly:
login(account: SelectedAccountFields): {
account: SelectedAccountFields;
access_token: string;
} {
const payload: JwtPayload = {
sub: account.id,
username: account.username,
display_name: account.display_name,
image: account.image,
role: account.role,
};
return {
account,
access_token: this.jwtService.sign(payload, {
secret: this.configService.get<string>(ProcessEnv.JWT_SECRET),
}),
};
}
abstract validateAccount(...data: any): Promise<SelectedAccountFields> | null;
LocalAuthService validates a password. GoogleAuthService validates a Google access token. Both inherit the same JWT creation method. The authentication flow therefore varies where the credentials differ and stays shared where the result is the same.
Notification services also extend a shared service for reading, updating, and deleting stored notifications. The specialized follow, comment, and post notification services add the creation behavior relevant to each event. Inheritance provides shared behavior; composition connects a class to the other services it coordinates.
NestJS provider tokens also make the authorization guard reusable. Feature modules bind MANAGE_DATA_SERVICE to the service that loads their own entity, such as PostsService for posts or BookmarksService for bookmarks. The same CanManageData guard can then load the relevant object and apply a CASL rule. This is another place where an interface and dependency injection connect a shared rule to different resource types.
Custom decorators and request handling
The project has several custom decorators. Some are parameter decorators that give controller methods data already placed on the request. Others attach metadata to a route so a guard can read configuration for that specific action.
Parameter decorators
@Client() reads the authenticated user from request.user. @Data() reads a resource loaded and authorized by CanManageData. @AccountCredentials() reads the account placed on the request by PasswordsMatch. @VerifiedGoogleUser() reads the profile obtained by the Google verification guard. @VerificationCodeObj() reads the code loaded by the verification guard. These decorators let a controller name the object it needs without repeating request-property lookups.
The @Client() implementation can return the whole user or one field from the JWT payload:
export const Client = createParamDecorator(
(data: keyof JwtPayload, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user[data] : user;
},
);
For account credentials, the guard and decorator form a pair. PasswordsMatch compares the password and places the account on the request. The decorator retrieves that result for the controller:
request.account_credentials = account;
return true;
export const AccountCredentials = createParamDecorator(
(data: keyof AccountWithCredentials, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const account = request.account_credentials;
return data ? account[data] : account;
},
);
The same pattern handles authorization and verification codes. CanManageData attaches the authorized resource as request.data, which @Data() reads. VerificationCodeMatches attaches the matched database record as request.verification_code, which @VerificationCodeObj() reads. The guard performs the check; the decorator only exposes its result.
Route metadata
The route decorators @NotificationTo() and @VerificationCodeProcess() store the notification channel and the kind of verification being requested. A guard can read those values with NestJS Reflector instead of using separate guard classes for every email, phone, or account action. The account and two-factor controllers also use @FollowingURL() to record the next route in a multi-step process.
For example, the local account controller marks the action for adding an email address with both its delivery channel and process type:
@NotificationTo(NotificationBy.EMAIL)
@VerificationCodeProcess(CodeProcess.ADD_EMAIL_TO_ACCOUNT)
@FollowingURL(LOCAL_ACCOUNTS_ROUTE + AccountRoutes.VERIFY_PROCESS)
@UseGuards(
JwtAuthGuard,
IsLocalAccount,
PasswordsMatch,
VerificationCodeAlreadySentToAccount,
)
@Post(AccountRoutes.ADD_EMAIL)
The metadata describes the action; the guards check the JWT, account type, password, and existing verification state. The controller then sends a code and returns a follow-up URL. The route declaration lists the checks applied before code delivery.
Validation and input processing
The API applies a global NestJS ValidationPipe, and several routes use extra DTO validation middleware before their guards run. The middleware validates request bodies or route parameters with class-validator. This matters for verification flows because a guard may need a valid password, token, or code from the request before the controller is called.
Custom validation rules require usernames to contain at least two letters and no more than two underscores, using only letters, digits, and underscores. IsNotBlank rejects strings made only of whitespace. The DTO combines those rules with a length limit and, when creating a username, an asynchronous uniqueness check:
export class UniqueUsernameDto {
@UniqueUsername()
@Validate(MinTwoLetters)
@Validate(MaxTwoUnderscores)
@Validate(NotAllowSpecialCharsExcludeUnderScore)
@Length(2, 16)
@IsNotBlank()
username: string;
}
The uniqueness validators check both permanent accounts and temporary registration accounts. That prevents a pending registration from using a username, email address, or phone number already held by either kind of record. There is also an account-existence validator used for chat requests.
DTOs are composed with NestJS IntersectionType. The account creation DTO combines username, password, and display-name rules; the email and phone variants add the relevant contact and verification fields. The post creation DTO combines title and content rules. This reuses validation definitions across related request types.
Custom pipes handle input that needs more than field validation. TagsPipe accepts a tag array or a JSON-encoded array from form data, limits a post to three tags, checks their length, and accepts only letters and hyphens. Image pipes distinguish an optional image from a required one and accept JPEG, PNG, or WebP files. The post publish query is also represented by a DTO that accepts true or false.
Custom interceptors
The API uses custom NestJS interceptors for work around a controller method. They call next.handle() and use the resulting observable to create notifications, emit socket events, update response data, or schedule cleanup. This places a cross-cutting action on the route that needs it without putting that action in every controller method.
Notifications and live events
FollowedNotificationInterceptor, CommentedNotificationInterceptor, and RepliedNotificationInterceptor run after a follow, comment, or reply is created. Each creates a stored notification, then passes its ID to GatewayEventsService. The comment and reply interceptors check whether the sender is the recipient before creating the notification.
The comment interceptor shows the database-and-event sequence:
if (senderID !== notifableID) {
const notification =
await this.commentsNotificationService.createCommentNotification({
commentID: id,
senderID,
notifableID,
postID: post.id,
});
this.gatewayEventsService.newNotification(notification.id);
}
PostExpressionNotificationInterceptor and CommentExpressionNotificationInterceptor do the same kind of follow-up for likes and dislikes. They choose a notification action from the expression type, create a notification for the post or comment author, and pass the notification ID to the event service:
const action =
expression === PostExpressionType.LIKE
? NotificationActions.LIKED_POST
: NotificationActions.DISLIKED_POST;
const notification =
await this.postsNotificationService.createExpressionNotification({
notifableID: post.author.id,
senderID: account.id,
postID: post.id,
action,
});
this.gatewayEventsService.newNotification(notification.id);
NotifySubcribers is attached to post creation. It checks the created post’s published value and enqueues subscriber email and notification work only when the post is public. Its code is shown in the posts section.
NewMessageInterceptor runs after the message service saves a chat message. It forwards the saved message to GatewayEventsService, which sends it through the chat gateway, and returns the API response:
map((message: { data: NewMessageDto }) => {
this.gatewayEventsService.newMessage(message.data);
return { data: message.data, message: MessageMessages.SENT };
}),
Client-specific responses
CheckClientActionsOnPost adds bookmarked_by, liked_by, and disliked_by to a public post response when an optional JWT identifies the reader. It asks the bookmark and expression services about that account and post. CheckClientActionsOnComment adds liked_by and disliked_by to comment responses using the same idea.
The post interceptor begins with default values for a guest and looks up the signed-in client’s actions when a client exists:
let bookmarked_by = false;
let liked_by = false;
let disliked_by = false;
if (client) {
bookmarked_by = !!(await this.bookmarkService.getByPostAndAccount(
client.sub,
value.data.id,
));
const exp = await this.postExpresionService.checkAnyExpressionLeft(
client.sub,
value.data.id,
);
if (exp) {
const type: PostExpressionType = exp.expression;
switch (type) {
case PostExpressionType.LIKE:
liked_by = true;
break;
case PostExpressionType.DISLIKE:
disliked_by = true;
break;
default:
break;
}
}
}
CheckClientIsFollowing is attached to profile lookup and prepares following_by and subscription fields for the response. Its follow lookup is not awaited, so the response can retain the default values even when the client follows the profile.
SignNewJwtToken changes the response after a username, display name, or profile image update. It signs a token with the returned account data, then includes both the account and new token in the response:
const result = this.localAuthService.login(value.data);
const { access_token } = result;
return {
data: { access_token, account: value.data },
message: value.message,
};
Verification cleanup
DeleteVerificationCodeInBody is applied to endpoints that consume a verification code. After the controller completes, it calls the verification-code service to remove the code found in the request body:
if (request.body.verification_code) {
this.codesService.deleteByCode(request.body.verification_code);
}
DeleteTemporaryAccount runs after a registration-code request. It schedules removal of the temporary account by username if that registration remains unfinished:
this.tasksService.execAfterGivenMinutes(
() =>
this.tempAccountsService.deleteByUsernameIfExist(req.body.username),
6,
);
The email and SMS notification services separately schedule deletion of unused registration codes after five minutes and two-factor codes after two minutes. These timers use NestJS SchedulerRegistry and run inside the application process.
Cache and request logging
CachePublicJSON and CachePersonalJSON check Redis before calling the controller and cache a response after a miss. The personal interceptor’s key includes the account ID; its code is shown in caching, queues, and logging. LoggingInterceptor runs globally, records timing and request details after a successful response, and sends the record to the logging worker:
const data: LogData = {
client_id: request.user?.sub || 'guest',
start_time: request.start_time,
end_time: endTime,
response_time: calcResponseTime(request.start_time, endTime),
request_method: request.method,
request_url: request.url,
exception: null,
};
this.loggingWorker.produce(data);
Exception filters
Two-factor login has a specialized error flow. The local or Google authentication service can throw an exception when the account requires another factor. An email or mobile-phone exception filter catches that exception, checks whether a code was already sent, sends a new code when needed, and responds with the URL for the verification step. In that case, an exception represents an unfinished login.
There are also filters for database query failures, unavailable delivery services, and other exceptions. They shape the HTTP error response and send log data to the logging queue. These filters translate lower-level failures into API responses and record information for inspection.
Development environment
The repository includes a Docker Compose file for the API and its local dependencies. It starts PostgreSQL, Redis, RabbitMQ, and Elasticsearch, plus PgAdmin, RedisInsight, and an Elasticsearch UI. The inspection tools expose relational data in PostgreSQL, JSON cache entries in Redis, messages in RabbitMQ, and logs in Elasticsearch.
The API container mounts the source directory and runs the NestJS development command. The example environment file contains the database and broker URLs, JWT settings, provider credentials, and HTTPS settings. Cloudinary, Mailgun, and Twilio need their own credentials. Without working email or SMS credentials, flows that depend on sending a verification code cannot complete normally.
The TypeORM configuration uses the development database URL outside production and the production URL in production. Development enables schema synchronization; production uses migrations instead. The application starts Swagger under /api, applies a global validation pipe, and sets /api as the route prefix. The validation pipe rejects unknown values, removes fields that are not in the DTO, and stops at the first validation error.
The production bootstrap has an HTTPS path as well. It reads certificate files from configured paths, starts the NestJS application on port 443, and starts an HTTP listener on port 80 that redirects requests to the configured secure host. The Docker Compose production command builds the application, runs migrations, and starts it through PM2.
The README gives the local startup command:
docker compose up
There is also a separate Compose file for starting the test environment with the supporting services.
Testing
The repository contains unit tests around controllers, services, guards, and interceptors, especially in the account modules. These tests use mocks to check a class’s behavior without starting every dependency. Integration tests exercise account services with the database layer.
The Jest configuration separates unit, integration, and end-to-end projects. The implemented suites focus on unit and integration cases around accounts.
Unit tests
The unit tests replace repository and service dependencies with mocks, then check one controller, service, guard, or interceptor at a time. The account service tests cover lookups, credential selection, updates, and cache calls. Guard tests check conditions such as whether an account is local or Google-based and whether a password matches. Controller tests check the response and the service call for account actions.
The unit tests exercise decisions without launching PostgreSQL or calling an external API. The upload service and notification service use interfaces and injected implementations, so their callers can be tested without making a real Cloudinary, Mailgun, or Twilio request.
Test helpers create fake account and content data and provide a mock repository for service tests. The unit tests cover different account types and credential operations as well as the guards and interceptors around them.
Integration tests
The account integration tests create a Nest testing module with TypeORM and, where needed, Redis. They use repository operations to save and find accounts, then call the actual account service. One test updates an account and checks both the PostgreSQL record and its cached Redis value. Others check credential lookups through username, email address, and mobile phone number.
The account update test checks that a change is visible in both stores:
const account = await accountsRepository.save({ ...fakeAccount });
const cacheKey = CACHED_ROUTES.CLIENT_ACCOUNT + account.id;
await cacheJsonService.save({ key: cacheKey, data: account });
const newUsername = 'new_username';
accountsService.setUsername(account, newUsername);
await accountsService.update(account);
const updatedAccount = await accountsRepository.findOneBy({
id: account.id,
});
const updatedCache = await cacheJsonService.get(cacheKey);
expect(updatedAccount).not.toBeNull();
expect(updatedCache).not.toBeNull();
expect(updatedAccount.username).not.toBe(fakeAccount.username);
expect(updatedAccount.username).toBe(newUsername);
expect(updatedCache.username).toBe(newUsername);
The test Compose file starts the API with PostgreSQL, Redis, RabbitMQ, and Elasticsearch available.