Data Management and API Extensibility

Our SaaS boilerplate is designed for easy scalability and extensibility, particularly when it comes to data management and API endpoints. Here's an overview of how the system is set up and how you can extend it. As an example, we have implemented a data management system to demonstrate how you can monetize your data. It's up to you to decide which paid features you want to add.

API Architecture

  1. Prisma ORM:

    • The API is built on top of Prisma, a modern ORM that simplifies database operations and schema management.

    • Prisma provides type-safe database access and easy-to-use query building.

  2. Generic Controllers and Services:

    • The API includes generic controller and service implementations that handle common CRUD operations for any resource.

    • This generic approach allows for rapid development of new endpoints with minimal boilerplate code.

Extending the API

To add new resources and endpoints to your API, follow these steps:

  1. Define the Prisma Model:

    • Open your schema.prisma file.

    • Add a new model definition for your resource. For example:

      model Vehicle {
        id        String   @id @default(uuid())
        make      String
        model     String
        year      Int
        price     Float
        createdAt DateTime @default(now())
        updatedAt DateTime @updatedAt
      
        @@map("vehicles")
      }
  2. Generate Prisma Client:

    • Run npx prisma generate to update your Prisma client with the new model.

    • Run npm run prisma:generate to update your postgres database with the new model.

  3. Create DTO (Data Transfer Object):

    • In your src/vehicles/dtos directory, create a new CreateDTO file for your resource.

    import { ApiProperty } from '@nestjs/swagger';
    import { IsString, IsInt } from 'class-validator';
    import { Transform } from 'class-transformer';
    
    export class CreateVehicleDto {
        @ApiProperty({
            example: 'Toyota',
            description: 'The make of the vehicle',
            type: String,
        })
        @IsString()
        make: string;
    
        @ApiProperty({
            example: 'Camry',
            description: 'The model of the vehicle',
            type: String,
        })
        @IsString()
        model: string;
    
        @ApiProperty({
            example: 2022,
            description: 'The year of the vehicle',
            type: Number,
        })
        @IsInt()
        @Transform(({ value }) => parseInt(value, 10))
        year: number;
    
        @ApiProperty({
            example: 25000,
            description: 'The price of the vehicle',
            type: Number,
        })
        @IsInt()
        @Transform(({ value }) => parseInt(value, 10))
        price: number;
    }
    • Create the update DTO

    import { PartialType } from '@nestjs/swagger';
    import { CreateVehicleDto } from './create-vehicle.dto';
    
    export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {
    
    }
    
  4. Create Model File:

    • In your src/vehicles/model directory, create a new model file that corresponds to your Prisma model.

    • import { ApiProperty } from '@nestjs/swagger';
      
      export class Vehicle {
          @ApiProperty({
              example: '123e4567-e89b-12d3-a456-426614174000',
              description: 'The id of the vehicle',
              type: String,
          })
          id: string;
      
          @ApiProperty({
              example: 'Toyota',
              description: 'The make of the vehicle',
              type: String,
          })
          make: string;
      
          @ApiProperty({
              example: 'Camry',
              description: 'The model of the vehicle',
              type: String,
          })
          model: string;
      
          @ApiProperty({
              example: 2022,
              description: 'The year of the vehicle',
              type: Number,
          })
          year: number;
      
          @ApiProperty({
              example: 25000.50,
              description: 'The price of the vehicle',
              type: Number,
          })
          price: number;
      
          @ApiProperty({
              example: '2023-01-01T00:00:00Z',
              description: 'The creation date of the vehicle',
              type: Date,
          })
          createdAt: Date;
      
          @ApiProperty({
              example: '2023-01-01T00:00:00Z',
              description: 'The last update date of the vehicle',
              type: Date,
          })
          updatedAt: Date;
      }
    • This file should define the shape of your resource as it will be used in your application logic.

  5. API Endpoint Generation:

    • Thanks to the generic controller and service implementation, new CRUD endpoints for your resource are automatically available.

    • These endpoints will provide robust and scalable API access for creating, reading, updating, and deleting your new resource.

    • Even CSV upload can be configured

import { CrudController } from '@/common/crud/crud.controller';
import { BadRequestException, Controller, Post, UploadedFile } from '@nestjs/common';
import { Vehicle } from './model/vehicle.model';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { VehiclesService } from './vehicles.service';
import { ApiImportCSV } from '@/common/crud/decorators/swagger.decorators';
import { CsvImportOptions, CsvImportQuery } from '@/common/crud/csv/csv-import-query.dto';
import * as fs from 'fs';
import { multerOptions } from '@/common/crud/csv/multer.options';

@Controller('vehicles')
export class VehiclesController extends CrudController<
    Vehicle,
    CreateVehicleDto,
    UpdateVehicleDto
>(Vehicle, CreateVehicleDto, UpdateVehicleDto) {

    constructor(private readonly service: VehiclesService) {
        super(service);
    }

    @Post('import')
    @ApiImportCSV(multerOptions)
    async importUsers(
        @UploadedFile() file: Express.Multer.File,
        @CsvImportQuery() options: CsvImportOptions,
    ) {
        if (!file) throw new BadRequestException('No file uploaded');
        try {
            const result = await this.service.importCSV(file.path, options);
            fs.unlinkSync(file.path);
            return result;
        } catch (error) {
            fs.unlinkSync(file.path);
            if (error instanceof BadRequestException) throw error;
            throw new BadRequestException(`Failed to import CSV: ${error.message}`);
        }
    }

}
import {
    Injectable,
} from '@nestjs/common';
import { PrismaService } from '@/common/prisma/prisma.service';
import {
    CrudService,
    CsvOptions,
    ImportResult,
} from '@/common/crud/crud.service';

import { Vehicle } from './model/vehicle.model';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';

@Injectable()
export class VehiclesService extends CrudService<
    Vehicle,
    CreateVehicleDto,
    UpdateVehicleDto
> {

    constructor(
        protected prisma: PrismaService
    ) {
        super(prisma, prisma.vehicle, 'Vehicle');
    }

    public async importCSV(
        filePath: string,
        options: CsvOptions,
    ): Promise<ImportResult> {
        return this.importFromCsv(filePath, {
            ...options,
            transformAndValidate: async (data) => {
                const item = plainToClass(CreateVehicleDto, {
                    ...data,
                    __csvImport: true,
                });
                const errors = await validate(item);
                if (errors.length > 0) throw new Error(errors.toString());
                delete (item as any).__csvImport;
                return item;
            },
        });
    }


}

With these steps, your API will automatically have endpoints for managing products, including:

  • GET /vehicles

  • GET /vehicles/:id

  • POST /vehicles

  • PUT /vehicles/:id

  • DELETE /vehicles/:id

Here are example tests to illustrate ( users model )

import {
  INestApplication,
  NotFoundException,
  ValidationPipe,
} from '@nestjs/common';
import * as request from 'supertest';
import { PrismaService } from '@/common/prisma/prisma.service';
import { UsersService } from '@/users/users.service';
import { Test, TestingModule } from '@nestjs/testing';
import { AppModule } from '@/app.module';

describe('UserController CRUD', () => {
  let app: INestApplication;
  let prismaService: PrismaService;
  let userService: UsersService;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        transform: true,
        transformOptions: { enableImplicitConversion: true },
      }),
    );
    await app.init();

    prismaService = app.get<PrismaService>(PrismaService);
    userService = app.get<UsersService>(UsersService);
    await prismaService.user.deleteMany();
  });

  async function clearDatabase() {
    const tablenames = await prismaService.$queryRaw<
      Array<{ tablename: string }>
    >`SELECT tablename FROM pg_tables WHERE schemaname='public'`;

    for (const { tablename } of tablenames) {
      if (tablename !== '_prisma_migrations') {
        try {
          await prismaService.$executeRawUnsafe(
            `TRUNCATE TABLE "public"."${tablename}" CASCADE;`,
          );
        } catch (error) {
          console.log({ error });
        }
      }
    }
  }

  beforeEach(async () => {
    await clearDatabase();
  });

  afterAll(async () => {
    await clearDatabase();
  });

  const seedUsers = async () => {
    await prismaService.user.createMany({
      data: [
        {
          firebaseId: '1',
          email: 'user1@example.com',
          displayName: 'User 1',
          active: true,
        },
        {
          firebaseId: '2',
          email: 'user2@example.com',
          displayName: 'User 2',
          active: true,
        },
        {
          firebaseId: '3',
          email: 'user3@example.com',
          displayName: 'User 3',
          active: true,
        },
        {
          firebaseId: '4',
          email: 'user4@example.com',
          displayName: 'User 4',
          active: true,
        },
        {
          firebaseId: '5',
          email: 'user5@example.com',
          displayName: 'User 5',
          active: true,
        },
      ],
    });
  };

  describe('GET /users', () => {
    beforeEach(async () => {
      await seedUsers();
    });

    it('should get all users', async () => {
      console.log('should get all users');
      const response = await request(app.getHttpServer())
        .get('/users')
        .expect(200);
      expect(response.body.data).toHaveLength(5);
    });

    it('should get users with pagination', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?page=1&pageSize=1')
        .expect(200);

      expect(response.body.data).toHaveLength(1);
    });

    it('should be able to order users with orderBy={"displayName": "desc" }', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?orderBy={"displayName": "desc"}')
        .expect(200);

      expect(response.body.data).toHaveLength(5);
      expect(response.body.data[0].displayName).toBe('User 5');
    });

    it('should be able to select only id and displayName fields', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?select={ "id" : true , "displayName": true }')
        .expect(200);

      expect(response.body.data).toHaveLength(5);
      expect(response.body.data[0]).toHaveProperty('id');
      expect(response.body.data[0]).toHaveProperty('displayName');
    });

    it('should be able to filter users where[displayName][startsWith]=User 1', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[displayName][startsWith]=User 1')
        .expect(200);

      expect(response.body.data).toHaveLength(1);
      expect(response.body.data[0].displayName).toBe('User 1');
    });

    it('should be able to filter users where[displayName][endsWith]=2', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[displayName][endsWith]=2')
        .expect(200);

      expect(response.body.data).toHaveLength(1);
      expect(response.body.data[0].displayName).toBe('User 2');
    });

    it('should be able to filter users with where[displayName]=User 3', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[displayName]=User 3')
        .expect(200);

      expect(response.body.data).toHaveLength(1);
      expect(response.body.data[0].displayName).toEqual('User 3');
    });

    it('should be able to filter users where[displayName][not]=User 1', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[displayName][not]=User 1')
        .expect(200);

      expect(response.body.data).toHaveLength(4);
      expect(response.body.data.map((user) => user.displayName)).not.toContain(
        'User 1',
      );
    });

    it('should be able to filter users where[displayName][contains]=1', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[displayName][contains]=1')
        .expect(200);

      expect(response.body.data).toHaveLength(1);
      expect(response.body.data[0].displayName).toBe('User 1');
    });

    it('should be able to filter users with where[displayName][contains]=user 3 (insentive search)', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[displayName]=User 3')
        .expect(200);

      expect(response.body.data).toHaveLength(1);
      expect(response.body.data[0].displayName).toEqual('User 3');
    });

    it('should be able to filter users where[displayName][in]=["User 1", "User 2"]', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[displayName][in]=["User 1", "User 2"]')
        .expect(200);

      expect(response.body.data).toHaveLength(2);
    });

    it('should get users?select={"id" : true, "displayName" : true }&where[age][lt]=40', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?select={"id" : true, "displayName" : true }&where[age][lt]=40')
        .expect(200);

      expect(response.body.data).toHaveLength(3);
      expect(response.body.data[0]).toHaveProperty('id');
      expect(response.body.data[0]).toHaveProperty('displayName');
      expect(response.body.data[0]).not.toHaveProperty('age');
    });

    it('should be able to filter users where[age]=40', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[age]=40')
        .expect(200);

      expect(response.body.data).toHaveLength(1);
      expect(response.body.data[0].age).toBe(40);
    });

    it('should be able to filter users where[age][gt]=30', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[age][gt]=30')
        .expect(200);

      expect(response.body.data).toHaveLength(3);
      expect(response.body.data.map((user) => user.age)).toEqual(
        expect.arrayContaining([40, 35, 45]),
      );
    });

    it('should be able to filter users where[age][lt]=30', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[age][lt]=30')
        .expect(200);

      expect(response.body.data).toHaveLength(1);
      expect(response.body.data[0].age).toBe(25);
    });

    it('should be able to filter users where[age][gte]=30', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[age][gte]=30')
        .expect(200);

      expect(response.body.data).toHaveLength(4);
      expect(response.body.data.map((user) => user.age)).toEqual(
        expect.arrayContaining([30, 35, 40, 45]),
      );
    });

    it('should be able to filter users where[age][lte]=30', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[age][lte]=30')
        .expect(200);

      expect(response.body.data).toHaveLength(2);
      expect(response.body.data.map((user) => user.age)).toEqual(
        expect.arrayContaining([25, 30]),
      );
    });

    it('should be able to filter users where[age][in]=[30, 35]', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[age][in]=[30, 35]')
        .expect(200);

      expect(response.body.data).toHaveLength(2);
      expect(response.body.data.map((user) => user.age)).toEqual(
        expect.arrayContaining([30, 35]),
      );
    });

    it('should be able to filter users where[age][not][in]=[30, 35]', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where[age][not][in]=[30, 35]')
        .expect(200);

      expect(response.body.data).toHaveLength(3);
      expect(response.body.data.map((user) => user.age)).toEqual(
        expect.arrayContaining([25, 40, 45]),
      );
    });

    it('should be able to filter users ?where={"OR":[{"displayName":"User 1"},{"age":{"gte":45}}]}', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where={"OR":[{"displayName":"User 1"},{"age":{"gte":45}}]}')
        .expect(200);

      expect(response.body.data).toHaveLength(2);
    });

    it('should be able to filter users where={"AND":[{"displayName":"User 5"},{"age":{"gte":45}}]}', async () => {
      const response = await request(app.getHttpServer())
        .get('/users?where={"AND":[{"displayName":"User 5"},{"age":{"gte":45}}]}')
        .expect(200);

      expect(response.body.data).toHaveLength(1);
    });
  });

  describe('CRUD operations', () => {
    it('should get a BadRequestException with invalid payload when creating a user', async () => {
      console.log(
        'should get a BadRequestException with invalid payload when creating a user',
      );
      const user = {
        email: 'Murray56', // => should be an email
        displayName: 'Clyde Lockman',
      };

      await request(app.getHttpServer()).post('/users').send(user).expect(400);
    });

    it('should get a BadRequestException with invalid payload when creating a user (nested)', async () => {
      const user = {
        email: 'Murray56@gmail.com',
        displayName: 'Clyde Lockman',
        age: 1,
        posts: [
          {
            title: 12344,
          },
        ],
      };

      await request(app.getHttpServer()).post('/users').send(user).expect(400);
    });

    it('should create a user', async () => {
      const newUser = {
        firebaseId: '1',
        email: 'user1@example.com',
        displayName: 'User 1',
        active: true,
      };
      const response = await request(app.getHttpServer())
        .post('/users')
        .send(newUser)
        .expect(201);

      expect(response.body).toHaveProperty('id');
      expect(response.body.email).toBe(newUser.email);
      expect(response.body.displayName).toBe(newUser.displayName);
    });

    it('should create a user with his posts', async () => {
      const newUser = {
        email: 'test2@example.com',
        displayName: 'Test User',
        age: 30,
        posts: [
          {
            title: 'Post 1',
            content: 'Content 1',
            published: true,
          },
        ],
      };

      const response = await request(app.getHttpServer())
        .post('/users')
        .send(newUser)
        .expect(201);

      expect(response.body).toHaveProperty('id');
      expect(response.body.email).toBe(newUser.email);
      expect(response.body.displayName).toBe(newUser.displayName);
      expect(response.body.age).toBe(newUser.age);
    });

    it('should get a user by id', async () => {
      const user = await userService.create({
        firebaseId: '2',
        email: 'get@example.com',
        displayName: 'Get User',
      });

      const response = await request(app.getHttpServer())
        .get(`/users/${user.id}`)
        .expect(200);

      expect(response.body).toEqual({
        ...user,
        createdAt: expect.any(String),
        updatedAt: expect.any(String),
      });

      expect(new Date(response.body.createdAt)).toEqual(user.createdAt);
      expect(new Date(response.body.updatedAt)).toEqual(user.updatedAt);
    });

    it('should update a user', async () => {
      const user = await userService.create({
        firebaseId: '3',
        email: 'update@example.com',
        displayName: 'Update User',
      });

      const updatedData = { displayName: 'Updated displayName' };

      const response = await request(app.getHttpServer())
        .put(`/users/${user.id}`)
        .send(updatedData)
        .expect(200);

      expect(response.body.displayName).toBe(updatedData.displayName);
    });

    it('should delete a user', async () => {
      const user = await userService.create({
        firebaseId: '4',
        email: 'delete@example.com',
        displayName: 'Delete User',
      });

      await request(app.getHttpServer())
        .delete(`/users/${user.id}`)
        .expect(200);
      await expect(
        userService.findOne({ where: { id: user.id } }),
      ).rejects.toThrow(NotFoundException);
    });
  });
});

This architecture allows you to rapidly extend your API to support new resources and functionality with minimal additional code.

Remember to update your database with the new schema changes by running npx prisma migrate dev after modifying your Prisma schema.

Data Management and Premium Access

Our SaaS boilerplate is designed with a built-in system for managing premium access to data. Here's how it works:

Default Premium Data Access

By default, the boilerplate includes a feature where purchasing any product unlocks access to premium data. This serves as an example of how you can gate certain functionalities behind a paywall.

Purchase and Unlock Process

  1. Product Purchase:

    • Users can purchase products through the integrated Stripe payment system.

    • In development, use Stripe's test mode to verify the process.

    • Remember to switch to live mode when deploying your SaaS for real transactions.

  2. Payment Flow:

    • Upon successful payment, the user is redirected to /stripe/checkout/success.

    • This redirection triggers the system to link the purchased product to the user's account.

  3. Account Upgrade:

    • The successful purchase automatically upgrades the user's account to premium status.

    • This unlocks access to the premium data feature.

  4. Email Confirmation:

    • After the purchase, the user receives a confirmation email.

    • This email is automatically handled and sent by the API.

Implementation Details

  • The API handles the Stripe webhook to process successful payments.

  • User status is updated in the database to reflect their premium access.

  • Access control is implemented throughout the application to check user status before allowing access to premium features.

Exports

CSV and PDF export options are built in for your data, with customization options to meet all your needs.

Customizing Premium Features

While the default setup unlocks data access, you can easily modify this to:

  • Unlock different features based on specific product purchases.

  • Implement tiered access levels.

  • Create time-limited access based on subscription models.

Testing the Process

  1. Use Stripe's test card numbers to simulate purchases.

  2. Verify that the redirect to /stripe/checkout/success occurs.

  3. ( You ca use Ngrok to use the Stripe Webhook, and simulate everything in local environment )

Then,

  1. Check that the user's status is updated in your database.

  2. Confirm that the confirmation email is sent and received.

  3. Test accessing premium features with the upgraded account.

Remember to thoroughly test this flow in your development environment before going live. Ensure all components - Stripe integration, database updates, email sending, and access control - work seamlessly together.

By leveraging this pre-built premium access system, you can quickly implement and customize monetization strategies for your SaaS application, focusing on creating value-added features for your premium users.

Last updated