Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backend] Add feature: manage subscriptions #11

Merged
merged 10 commits into from
Apr 14, 2024
Merged
4 changes: 3 additions & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"nrwl.angular-console",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"vivaxy.vscode-conventional-commits"
"vivaxy.vscode-conventional-commits",
"prisma.prisma",
"abians.prisma-generate-uml"
]
}
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,8 @@
},
"prisma": {
"schema": "src/database/schema.prisma"
},
"volta": {
"extends": "../../package.json"
}
}
29 changes: 13 additions & 16 deletions apps/api/src/app/app.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,37 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing'
import { Request } from 'express'
import { createMock } from '@golevelup/ts-jest'
import { Logger } from '@nestjs/common'
import { TestBed } from '@automock/jest'

import { AppController } from './app.controller'
import { AppService } from './app.service'

describe('AppController', () => {
let app: TestingModule
let appController: AppController
let logger: Logger
let controller: AppController
let logger: jest.Mocked<Logger>
let appService: jest.Mocked<AppService>

const request = createMock<Request>()

beforeAll(async () => {
app = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService, Logger]
})
.useMocker(createMock)
.compile()
const { unit, unitRef } = TestBed.create(AppController).compile()

appController = app.get<AppController>(AppController)
logger = app.get<Logger>(Logger)
controller = unit
appService = unitRef.get(AppService)
logger = unitRef.get(Logger)
})

describe('getData', () => {
it('should return "Hello API"', () => {
expect(appController.getData(request)).toEqual({ message: 'Hello API' })
appService.getData.mockReturnValue({ message: 'Hello API' })

const response = controller.getData(request)
expect(response).toEqual({ message: 'Hello API' })
})

it('should log the request context', () => {
jest.spyOn(logger, 'log')

appController.getData(request)
controller.getData(request)

expect(logger.log).toHaveBeenCalledWith(request, 'AppController#getData')
})
Expand Down
21 changes: 18 additions & 3 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
import { Logger, Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { APP_PIPE } from '@nestjs/core'
import { ZodValidationPipe } from 'nestjs-zod'
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod'

import { DatabaseModule } from '../database/database.module'
import { MoviesModule } from '../movies/movies.module'
import { SubscriptionsModule } from '../subscriptions/subscriptions.module'

import { AppController } from './app.controller'
import { AppService } from './app.service'
import { validate } from './config/validate'
import { HttpExceptionFilter } from './http-exception.filter'

@Module({
imports: [ConfigModule.forRoot({ isGlobal: true, validate }), DatabaseModule, MoviesModule],
imports: [
ConfigModule.forRoot({ isGlobal: true, validate }),
DatabaseModule,
MoviesModule,
SubscriptionsModule
],
controllers: [AppController],
providers: [
Logger,
AppService,
{
provide: APP_PIPE,
useClass: ZodValidationPipe
},
{
provide: APP_INTERCEPTOR,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what we're doing here is basically addding a validation middleware of the incoming payload? Or?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

incoming payload validation would be the block above (APP_PIPE and ZodValidationPipe), this one is about how you want response payload to be shown

useClass: ZodSerializerInterceptor
},
{
provide: APP_FILTER,
useClass: HttpExceptionFilter
}
]
})
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/app/http-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, Logger } from '@nestjs/common'
import { Request, Response } from 'express'

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if I understood correctly this is an error handling middleware?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Precisely! The framework has a nice built-in global error handler already, but it was omitting errors from Zod schemas. I had no idea what was failing so I wrote another one myself, just to console.log the error

In the end, it helped me to identify that I was typing .optional() instead of .nullable()

constructor (private readonly logger: Logger) {}
catch (exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const request = ctx.getRequest<Request>()
const status = exception.getStatus()

this.logger.log(exception)

response
.status(status)
.json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- CreateTable
CREATE TABLE "companies" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"image" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "companies_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "subscriptions" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"company_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "subscription_credentials" (
"id" SERIAL NOT NULL,
"subscription_id" INTEGER NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "subscription_credentials_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "subscriptions_user_id_company_id_idx" ON "subscriptions"("user_id", "company_id");

-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_user_id_company_id_key" ON "subscriptions"("user_id", "company_id");

-- CreateIndex
CREATE UNIQUE INDEX "subscription_credentials_subscription_id_key" ON "subscription_credentials"("subscription_id");

-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_company_id_fkey" FOREIGN KEY ("company_id") REFERENCES "companies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "subscription_credentials" ADD CONSTRAINT "subscription_credentials_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
3 changes: 3 additions & 0 deletions apps/api/src/database/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
52 changes: 49 additions & 3 deletions apps/api/src/database/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,53 @@ datasource db {
}

model User {
id Int @id @default(autoincrement())
email String @unique
name String?
id Int @id @default(autoincrement())
name String
email String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

subscriptions Subscription[]

@@map("users")
}

model Company {
id Int @id @default(autoincrement())
name String
image String
createdAt DateTime @default(now()) @map("created_at")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why we name these with camelCase and map them to a snakeCase? Similar to how we do with DTO's in Klarna to validate when data is confirmed to be type safe?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof nice catch! This is because databases usually has a different naming convention (plural and snake_case) from JS naming convention (singular and camelCase) and we want both to coexist.

This is described in more details in this Prisma doc. Schema file gets a little bit more verbose but code and database is standardized in a nice way

In short, in the left you read as field names while coding (client generated by Prisma) and on the right (the @Map field) is how you will see in the database tables

updatedAt DateTime @updatedAt @map("updated_at")

subscriptions Subscription[]

@@map("companies")
}

model Subscription {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int @map("user_id")
company Company @relation(fields: [companyId], references: [id])
companyId Int @map("company_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

subscriptionCredential SubscriptionCredential?

@@unique([userId, companyId])
@@index([userId, companyId])
@@map("subscriptions")
}

model SubscriptionCredential {
id Int @id @default(autoincrement())
subscription Subscription @relation(fields: [subscriptionId], references: [id])
subscriptionId Int @unique @map("subscription_id")
username String
password String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

@@map("subscription_credentials")
}
13 changes: 13 additions & 0 deletions apps/api/src/subscriptions/dto/create-subscription.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createZodDto } from 'nestjs-zod'
import { z } from 'nestjs-zod/z'

const SubscriptionSchema = z.object({
userId: z.number(),
companyId: z.number(),
credentials: z.object({
username: z.string(),
password: z.password()
}).optional()
})

export class CreateSubscriptionDto extends createZodDto(SubscriptionSchema) {}
5 changes: 5 additions & 0 deletions apps/api/src/subscriptions/dto/update-subscription.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger'

import { CreateSubscriptionDto } from './create-subscription.dto'

export class UpdateSubscriptionDto extends PartialType(CreateSubscriptionDto) {}
17 changes: 17 additions & 0 deletions apps/api/src/subscriptions/entities/subscription.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createZodDto } from 'nestjs-zod'
import { z } from 'nestjs-zod/z'

const SubscriptionResponseSchema = z.object({
id: z.number(),
userId: z.number(),
companyId: z.number(),
credentials: z.object({
id: z.number(),
username: z.string()
// Hides password in any response payload
}).nullable(),
createdAt: z.date(),
updatedAt: z.date()
})

export class SubscriptionResponse extends createZodDto(SubscriptionResponseSchema) {}
68 changes: 68 additions & 0 deletions apps/api/src/subscriptions/subscriptions.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { TestBed } from '@automock/jest'

import { SubscriptionsController } from './subscriptions.controller'
import { SubscriptionsService } from './subscriptions.service'

// TODO: Replace with model factories
const mockSubscription = {
id: 1,
userId: 1,
companyId: 1
}
const mockSubscriptionWithCredentials = {
id: 1,
userId: 1,
companyId: 1,
credentials: {
username: 'test',
password: 'test'
}
}

describe('SubscriptionsController', () => {
let controller: SubscriptionsController

beforeEach(async () => {
const { unit } = TestBed.create(SubscriptionsController)
.mock(SubscriptionsService)
.using({
findAll: jest.fn().mockResolvedValue([mockSubscription, mockSubscriptionWithCredentials]),
findOne: jest.fn().mockResolvedValue(mockSubscription),
create: jest.fn().mockResolvedValue(mockSubscription),
update: jest.fn().mockResolvedValue(mockSubscription),
remove: jest.fn().mockResolvedValue(mockSubscription)
})
.compile()

controller = unit
})

it('should be defined', () => {
expect(controller).toBeDefined()
})

it('should return an array of subscriptions', async () => {
const subscriptions = await controller.findAll()
expect(subscriptions).toEqual([mockSubscription, mockSubscriptionWithCredentials])
})

it('should return a subscription by id', async () => {
const subscription = await controller.findOne('1')
expect(subscription).toEqual(mockSubscription)
})

it('should create a subscription', async () => {
const subscription = await controller.create(mockSubscription)
expect(subscription).toEqual(mockSubscription)
})

it('should update a subscription', async () => {
const subscription = await controller.update('1', mockSubscription)
expect(subscription).toEqual(mockSubscription)
})

it('should remove a subscription', async () => {
const subscription = await controller.remove('1')
expect(subscription).toEqual(mockSubscription)
})
})
Loading
Loading