Skip to main content

Overview

Controllers in Postiz handle HTTP requests and responses. They are the entry point for all API endpoints and follow strict patterns for consistency and maintainability.

Controller Structure

Controllers are located in apps/backend/src/api/routes/:
apps/backend/src/api/routes/
├── auth.controller.ts
├── posts.controller.ts
├── analytics.controller.ts
├── integrations.controller.ts
└── ... (other controllers)

Basic Controller Pattern

Here’s the standard pattern for creating a controller:
auth.controller.ts
import {
  Body,
  Controller,
  Get,
  Post,
  Param,
  Query,
  Req,
  Res,
} from '@nestjs/common';
import { Response, Request } from 'express';
import { ApiTags } from '@nestjs/swagger';
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto';
import { RealIP } from 'nestjs-real-ip';
import { UserAgent } from '@gitroom/nestjs-libraries/user/user.agent';

@ApiTags('Auth')
@Controller('/auth')
export class AuthController {
  constructor(
    private _authService: AuthService,
    private _emailService: EmailService
  ) {}

  @Get('/can-register')
  async canRegister() {
    return {
      register: await this._authService.canRegister('LOCAL'),
    };
  }

  @Post('/register')
  async register(
    @Req() req: Request,
    @Body() body: CreateOrgUserDto,
    @Res({ passthrough: false }) response: Response,
    @RealIP() ip: string,
    @UserAgent() userAgent: string
  ) {
    try {
      const { jwt, addedOrg } = await this._authService.routeAuth(
        body.provider,
        body,
        ip,
        userAgent
      );

      response.cookie('auth', jwt, {
        domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
        secure: true,
        httpOnly: true,
        sameSite: 'none',
        expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
      });

      response.status(200).json({ register: true });
    } catch (e: any) {
      response.status(400).send(e.message);
    }
  }
}

Key Decorators

Class-level Decorators

@ApiTags('Auth')
@Controller('/auth')
export class AuthController {}

Method-level Decorators

@Get('/list')          // GET /posts/list
@Post('/create')       // POST /posts/create
@Put('/update/:id')    // PUT /posts/update/:id
@Delete('/delete/:id') // DELETE /posts/delete/:id
@Patch('/patch/:id')   // PATCH /posts/patch/:id

Request Data Extraction

Path Parameters

@Get('/post/:id')
async getPost(@Param('id') id: string) {
  return this._postsService.getById(id);
}

Query Parameters

@Get('/posts')
async getPosts(
  @Query('page') page: number = 1,
  @Query('limit') limit: number = 10,
  @Query('search') search?: string,
) {
  return this._postsService.list({ page, limit, search });
}

Request Body

@Post('/create')
async createPost(@Body() body: CreatePostDto) {
  return this._postsService.create(body);
}
Always use DTOs (Data Transfer Objects) for request body validation. DTOs are located in libraries/nestjs-libraries/src/dtos/.

Custom Extractors

@Post('/login')
async login(
  @RealIP() ip: string,                    // Real IP address
  @UserAgent() userAgent: string,          // User agent string
  @Body() body: LoginUserDto,
) {
  return this._authService.login(body, ip, userAgent);
}

Response Handling

Simple JSON Response

@Get('/user')
async getUser() {
  return {
    id: '123',
    email: '[email protected]',
  };
}

Manual Response Control

@Post('/register')
async register(
  @Body() body: CreateOrgUserDto,
  @Res({ passthrough: false }) response: Response,
) {
  try {
    const result = await this._authService.register(body);
    
    // Set custom headers
    response.header('onboarding', 'true');
    
    // Set cookies
    response.cookie('auth', result.jwt, {
      httpOnly: true,
      secure: true,
    });
    
    // Send response
    response.status(200).json({ success: true });
  } catch (e: any) {
    response.status(400).send(e.message);
  }
}
When using @Res({ passthrough: false }), you must manually call response.json() or response.send(). NestJS will not automatically serialize the return value.

Setting Cookies

response.cookie('auth', jwt, {
  domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
  ...(!process.env.NOT_SECURED
    ? {
        secure: true,
        httpOnly: true,
        sameSite: 'none',
      }
    : {}),
  expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year
});

Error Handling

Try-Catch Pattern

@Post('/create')
async createPost(@Body() body: CreatePostDto) {
  try {
    return await this._postsService.create(body);
  } catch (e: any) {
    throw new Error(e.message);
  }
}

HTTP Exceptions

import {
  BadRequestException,
  NotFoundException,
  UnauthorizedException,
  ForbiddenException,
} from '@nestjs/common';

@Get('/post/:id')
async getPost(@Param('id') id: string) {
  const post = await this._postsService.findById(id);
  
  if (!post) {
    throw new NotFoundException('Post not found');
  }
  
  return post;
}

Dependency Injection

Constructor Injection

export class PostsController {
  constructor(
    private _postsService: PostsService,
    private _analyticsService: AnalyticsService,
    private _mediaService: MediaService,
  ) {}
}
Services are injected via the constructor. Use the private keyword to automatically create and assign class properties.

Validation with DTOs

Creating a DTO

DTOs are located in libraries/nestjs-libraries/src/dtos/:
create.post.dto.ts
import { IsString, IsOptional, IsArray, IsDateString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class CreatePostDto {
  @ApiProperty()
  @IsString()
  content: string;

  @ApiProperty({ required: false })
  @IsOptional()
  @IsString()
  image?: string;

  @ApiProperty()
  @IsArray()
  integrations: string[];

  @ApiProperty()
  @IsDateString()
  scheduledAt: string;
}

Using DTOs in Controllers

@Post('/create')
async createPost(@Body() body: CreatePostDto) {
  // body is automatically validated
  return this._postsService.create(body);
}

OAuth Flow Example

auth.controller.ts
@Get('/oauth/:provider')
async oauthLink(
  @Param('provider') provider: string,
  @Query() query: any,
) {
  return this._authService.oauthLink(provider, query);
}

@Post('/oauth/:provider/exists')
async oauthExists(
  @Body('code') code: string,
  @Param('provider') provider: string,
  @Res({ passthrough: false }) response: Response,
) {
  const { jwt, token } = await this._authService.checkExists(provider, code);

  if (token) {
    return response.json({ token });
  }

  response.cookie('auth', jwt, {
    domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
    secure: true,
    httpOnly: true,
    sameSite: 'none',
    expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
  });

  response.header('reload', 'true');
  response.status(200).json({ login: true });
}

Swagger Documentation

Basic Documentation

import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';

@ApiTags('Posts')
@Controller('/posts')
export class PostsController {
  @Get('/list')
  @ApiOperation({ summary: 'Get all posts' })
  @ApiResponse({ status: 200, description: 'List of posts' })
  @ApiResponse({ status: 401, description: 'Unauthorized' })
  async getPosts() {
    return this._postsService.list();
  }
}

Real-World Example

Here’s a complete controller from the codebase:
auth.controller.ts
import {
  Body,
  Controller,
  Get,
  Param,
  Post,
  Res,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiTags } from '@nestjs/swagger';
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
import { ForgotPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot.password.dto';
import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto';

@ApiTags('Auth')
@Controller('/auth')
export class AuthController {
  constructor(private _authService: AuthService) {}

  @Post('/forgot')
  async forgot(@Body() body: ForgotPasswordDto) {
    try {
      await this._authService.forgot(body.email);
      return { forgot: true };
    } catch (e) {
      return { forgot: false };
    }
  }

  @Post('/forgot-return')
  async forgotReturn(@Body() body: ForgotReturnPasswordDto) {
    const reset = await this._authService.forgotReturn(body);
    return { reset: !!reset };
  }

  @Post('/activate')
  async activate(
    @Body('code') code: string,
    @Res({ passthrough: false }) response: Response,
  ) {
    const activate = await this._authService.activate(code);
    
    if (!activate) {
      return response.status(200).json({ can: false });
    }

    response.cookie('auth', activate, {
      domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
      secure: true,
      httpOnly: true,
      sameSite: 'none',
      expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
    });

    response.header('onboarding', 'true');
    return response.status(200).json({ can: true });
  }
}

Best Practices

1

Keep controllers thin

Controllers should only handle HTTP concerns. Business logic belongs in services.
2

Use DTOs for validation

Always validate input using class-validator DTOs.
3

Follow the layer pattern

Controller → Service → Repository. Never skip layers.
4

Handle errors properly

Use try-catch blocks and appropriate HTTP exceptions.
5

Document with Swagger

Use @ApiTags, @ApiOperation, and @ApiResponse decorators.
6

Use dependency injection

Inject services via constructor, never instantiate manually.

Next Steps

Service Patterns

Learn how to implement service layer logic

Database Schema

Understand the database structure

Build docs developers (and LLMs) love