-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from all commits
74f8d24
667f7ee
ca9e677
2058a72
ded8840
265f7fa
813feac
4b01649
d38158b
64e6238
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -80,5 +80,8 @@ | |
}, | ||
"prisma": { | ||
"schema": "src/database/schema.prisma" | ||
}, | ||
"volta": { | ||
"extends": "../../package.json" | ||
} | ||
} |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So if I understood correctly this is an error handling middleware? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 In the end, it helped me to identify that I was typing |
||
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; |
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" |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
} |
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) {} |
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) {} |
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) {} |
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) | ||
}) | ||
}) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
andZodValidationPipe
), this one is about how you want response payload to be shown