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
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.
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:
Define the Prisma Model:
Open your schema.prisma file.
Add a new model definition for your resource. For example:
modelVehicle { id String@id@default(uuid()) make String model String year Int price Float createdAt DateTime@default(now()) updatedAt DateTime@updatedAt@@map("vehicles")}
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.
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';exportclassCreateVehicleDto { @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;}
In your src/vehicles/model directory, create a new model file that corresponds to your Prisma model.
import { ApiProperty } from'@nestjs/swagger';exportclassVehicle { @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.
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.
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 () => {constmoduleFixture:TestingModule=awaitTest.createTestingModule({ imports: [AppModule], }).compile(); app =moduleFixture.createNestApplication();app.useGlobalPipes(newValidationPipe({ transform:true, transformOptions: { enableImplicitConversion:true }, }), );awaitapp.init(); prismaService =app.get<PrismaService>(PrismaService); userService =app.get<UsersService>(UsersService);awaitprismaService.user.deleteMany(); });asyncfunctionclearDatabase() {consttablenames=awaitprismaService.$queryRaw< Array<{ tablename: string }>>`SELECT tablename FROM pg_tables WHERE schemaname='public'`;for (const { tablename } of tablenames) {if (tablename !=='_prisma_migrations') {try {awaitprismaService.$executeRawUnsafe(`TRUNCATE TABLE "public"."${tablename}" CASCADE;`, ); } catch (error) {console.log({ error }); } } } }beforeEach(async () => {awaitclearDatabase(); });afterAll(async () => {awaitclearDatabase(); });constseedUsers=async () => {awaitprismaService.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 () => {awaitseedUsers(); });it('should get all users',async () => {console.log('should get all users');constresponse=awaitrequest(app.getHttpServer()).get('/users').expect(200);expect(response.body.data).toHaveLength(5); });it('should get users with pagination',async () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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 () => {constresponse=awaitrequest(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', );constuser= { email:'Murray56',// => should be an email displayName:'Clyde Lockman', };awaitrequest(app.getHttpServer()).post('/users').send(user).expect(400); });it('should get a BadRequestException with invalid payload when creating a user (nested)',async () => {constuser= { email:'Murray56@gmail.com', displayName:'Clyde Lockman', age:1, posts: [ { title:12344, }, ], };awaitrequest(app.getHttpServer()).post('/users').send(user).expect(400); });it('should create a user',async () => {constnewUser= { firebaseId:'1', email:'user1@example.com', displayName:'User 1', active:true, };constresponse=awaitrequest(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 () => {constnewUser= { email:'test2@example.com', displayName:'Test User', age:30, posts: [ { title:'Post 1', content:'Content 1', published:true, }, ], };constresponse=awaitrequest(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 () => {constuser=awaituserService.create({ firebaseId:'2', email:'get@example.com', displayName:'Get User', });constresponse=awaitrequest(app.getHttpServer()).get(`/users/${user.id}`).expect(200);expect(response.body).toEqual({...user, createdAt:expect.any(String), updatedAt:expect.any(String), });expect(newDate(response.body.createdAt)).toEqual(user.createdAt);expect(newDate(response.body.updatedAt)).toEqual(user.updatedAt); });it('should update a user',async () => {constuser=awaituserService.create({ firebaseId:'3', email:'update@example.com', displayName:'Update User', });constupdatedData= { displayName:'Updated displayName' };constresponse=awaitrequest(app.getHttpServer()).put(`/users/${user.id}`).send(updatedData).expect(200);expect(response.body.displayName).toBe(updatedData.displayName); });it('should delete a user',async () => {constuser=awaituserService.create({ firebaseId:'4', email:'delete@example.com', displayName:'Delete User', });awaitrequest(app.getHttpServer()).delete(`/users/${user.id}`).expect(200);awaitexpect(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
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.
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.
Account Upgrade:
The successful purchase automatically upgrades the user's account to premium status.
This unlocks access to the premium data feature.
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
Use Stripe's test card numbers to simulate purchases.
Verify that the redirect to /stripe/checkout/success occurs.
( You ca use Ngrok to use the Stripe Webhook, and simulate everything in local environment )
Then,
Check that the user's status is updated in your database.
Confirm that the confirmation email is sent and received.
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.