diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac893f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20 + +ENV PORT=3000 +ENV STAGE=prod + +WORKDIR /var/prod +COPY package.json . +RUN npm install +COPY . . + +RUN npm run build + +EXPOSE ${PORT} + +CMD ["npm", "run", "start:prod"] + + diff --git a/package-lock.json b/package-lock.json index 6a1d671..d094b8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,9 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/typeorm": "^11.0.0", "dotenv": "^17.2.3", + "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "stripe": "^20.0.0", @@ -2513,6 +2515,19 @@ } } }, + "node_modules/@nestjs/typeorm": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", + "integrity": "sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -8236,6 +8251,95 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8354,6 +8458,45 @@ "node": ">= 0.4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8999,6 +9142,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", diff --git a/package.json b/package.json index c76c96f..107aa5d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/typeorm": "^11.0.0", "dotenv": "^17.2.3", + "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "stripe": "^20.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 4bdc165..a02a623 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,9 +4,25 @@ import { UserService } from './service/user.service'; import { BuyController } from './controller/buy.controller'; import { PurchaseService } from './service/purchase.service'; import { StripeService } from './service/stripe.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from './dto/user'; +import { Purchase } from './dto/purchase'; @Module({ - imports: [], + imports: [ + TypeOrmModule.forRoot({ + // @ts-ignore + type: process.env.DB_TYPE || 'postgres', + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 5432, + username: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + database: process.env.DB_NAME || 'appdb', + entities: [User, Purchase], + synchronize: true, + }), + TypeOrmModule.forFeature([User, Purchase]), + ], controllers: [AccessController, BuyController], providers: [UserService, PurchaseService, StripeService], }) diff --git a/src/controller/access.controller.ts b/src/controller/access.controller.ts index a70e693..895f126 100644 --- a/src/controller/access.controller.ts +++ b/src/controller/access.controller.ts @@ -14,12 +14,12 @@ export class AccessController { constructor(private readonly userService: UserService) {} @Post('/login') - public login( + public async login( @Body('name') name: string, @Body('password') password: string, - ): { userId: number } { + ) { this.logger.debug('Received login request'); - const user = this.userService.getUserByName(name); + const user = await this.userService.getUserByName(name); if (user.password !== password) { throw new UnauthorizedException(); } @@ -29,12 +29,12 @@ export class AccessController { } @Post('/register') - public new( + public async new( @Body('name') name: string, @Body('password') password: string, - ): { userId: number } { + ) { this.logger.debug('Received register request'); - const user = this.userService.createUser(name, password); + const user = await this.userService.createUser(name, password); return { userId: user.id, }; diff --git a/src/controller/buy.controller.ts b/src/controller/buy.controller.ts index 860f74b..b188367 100644 --- a/src/controller/buy.controller.ts +++ b/src/controller/buy.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Logger, Param, Post, Redirect } from '@nestjs/common'; +import { Body, Controller, Logger, Post } from '@nestjs/common'; import { PurchaseService } from '../service/purchase.service'; import { UserService } from '../service/user.service'; import { PurchaseItem } from '../dto/purchase-item'; @@ -7,6 +7,10 @@ import { PurchaseStatus } from 'src/dto/purchase-status'; @Controller('/buy') export class BuyController { + private static PURCHASE_STATUS_TO_REEVALUATE = [ + PurchaseStatus.CREATED, + PurchaseStatus.IN_PROGRESS, + ]; private readonly logger = new Logger(BuyController.name); constructor( @@ -17,32 +21,41 @@ export class BuyController { @Post('/start') public async startPurchaseFlow( - @Body('userId') userId: number, + @Body('userId') userId: string, @Body('quantity') quantity: number, ) { - const user = this.userService.getUserById(userId); - const purchase = this.purchaseService.recordPurchaseStart( + const user = await this.userService.getUserById(userId); + const purchase = await this.purchaseService.createPurchase( user, PurchaseItem.PAELLA, quantity, ); - const { sessionUrl } = await this.stripeService.createStripeSession( - user, - purchase, - ); + const { sessionId, sessionUrl } = + await this.stripeService.createStripeSession(purchase); + purchase.stripeSessionId = sessionId; + this.purchaseService.recordPurchase(purchase); this.logger.debug(`Redirecting to ${sessionUrl}`); return { url: sessionUrl }; } @Post('/complete') - public async complete(@Body('userId') userId: number) { - const user = this.userService.getUserById(userId); - const { isPaid, purchaseId } = - await this.stripeService.getStripeSessionForCustomer(user); - this.purchaseService.transitionPurchaseStatus( - purchaseId, - isPaid ? PurchaseStatus.COMPLETED : PurchaseStatus.IN_PROGRESS, - ); + public async complete(@Body('userId') userId: string) { + const user = await this.userService.getUserById(userId); + const purchases = await this.purchaseService.getPurchasesForUser(user); + purchases + .filter((purchase) => + BuyController.PURCHASE_STATUS_TO_REEVALUATE.includes(purchase.status), + ) + .forEach(async (purchase) => { + const { isPaid } = await this.stripeService.getStripeSessionFromId( + purchase.stripeSessionId!, + ); + purchase.status = isPaid + ? PurchaseStatus.COMPLETED + : PurchaseStatus.IN_PROGRESS; + this.purchaseService.recordPurchase(purchase); + }); + return { url: '/signup' }; } } diff --git a/src/dto/purchase.ts b/src/dto/purchase.ts index 60805e5..f564c0a 100644 --- a/src/dto/purchase.ts +++ b/src/dto/purchase.ts @@ -1,11 +1,25 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { PurchaseItem } from './purchase-item'; import { PurchaseStatus } from './purchase-status'; import { User } from './user'; +@Entity('PURCHASE') export class Purchase { - id: number; + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, (user) => user.purchases) purchasedBy: User; + + @Column() purchasedProduct: PurchaseItem; + + @Column() purchasedUnits: number; + + @Column() status: PurchaseStatus; + + @Column({ nullable: true }) + stripeSessionId?: string; } diff --git a/src/dto/stripe-session.ts b/src/dto/stripe-session.ts deleted file mode 100644 index bbaa089..0000000 --- a/src/dto/stripe-session.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class StripeSession { - sessionId: string; - userId: number; - purchaseId: number; -} diff --git a/src/dto/user.ts b/src/dto/user.ts index d017d09..c029fbd 100644 --- a/src/dto/user.ts +++ b/src/dto/user.ts @@ -1,5 +1,17 @@ +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { Purchase } from './purchase'; + +@Entity('USERS') export class User { - id: number; + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() name: string; + + @Column() password: string; + + @OneToMany(() => Purchase, (purchase) => purchase.purchasedBy) + purchases: Purchase[]; } diff --git a/src/service/purchase.service.ts b/src/service/purchase.service.ts index 413ae78..674d4c9 100644 --- a/src/service/purchase.service.ts +++ b/src/service/purchase.service.ts @@ -1,51 +1,39 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { Purchase } from 'src/dto/purchase'; import { PurchaseItem } from 'src/dto/purchase-item'; import { PurchaseStatus } from 'src/dto/purchase-status'; import { User } from 'src/dto/user'; +import { Repository } from 'typeorm'; @Injectable() export class PurchaseService { - private readonly purchases: Purchase[] = []; private readonly logger = new Logger(PurchaseService.name); - constructor() {} + constructor( + @InjectRepository(Purchase) private purchaseRepo: Repository, + ) {} - public recordPurchaseStart( + public createPurchase( user: User, purchasedItem: PurchaseItem, units: number, - ): Purchase { + ) { this.logger.debug(`Recording purchase for user ID: ${user.id}`); - const purchase: Purchase = { - id: this.purchases.length, + const purchase = this.purchaseRepo.create({ purchasedBy: user, purchasedProduct: purchasedItem, purchasedUnits: units, status: PurchaseStatus.CREATED, - }; - this.purchases.push(purchase); - return purchase; + }); + return this.purchaseRepo.save(purchase); } - public transitionPurchaseStatus( - purchaseId: number, - purchaseStatus: PurchaseStatus, - ) { - const purchase = this.purchases.find( - (purchase) => purchase.id === purchaseId, - ); - if (!purchase) { - throw new NotFoundException(); - } - - purchase.status = purchaseStatus; + public recordPurchase(purchase: Purchase) { + this.purchaseRepo.save(purchase); } - public findPurchasesByUser(userId: number): Purchase[] { - this.logger.debug(`Searching purchases for user ID: ${userId}`); - return this.purchases.filter( - (purchase) => (purchase.purchasedBy.id = userId), - ); + public getPurchasesForUser(user: User) { + return this.purchaseRepo.find({ where: { purchasedBy: user } }); } } diff --git a/src/service/stripe.service.ts b/src/service/stripe.service.ts index faab84d..4c95e63 100644 --- a/src/service/stripe.service.ts +++ b/src/service/stripe.service.ts @@ -1,21 +1,19 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Purchase } from '../dto/purchase'; import Stripe from 'stripe'; -import { User } from '../dto/user'; -import { StripeSession } from 'src/dto/stripe-session'; @Injectable() export class StripeService { private readonly stripe: Stripe; - private readonly stripeSessions: StripeSession[] = []; constructor() { const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY!; + console.log(stripePrivateKey); this.stripe = new Stripe(stripePrivateKey); } - public async createStripeSession(user: User, purchase: Purchase) { - const redirectUrl = `http://localhost:5173/buy/return?purchaseId=${purchase.id}` + public async createStripeSession(purchase: Purchase) { + const redirectUrl = `http://localhost:8000/buy/return?purchaseId=${purchase.id}`; const session = await this.stripe.checkout.sessions.create({ line_items: [ { @@ -28,32 +26,17 @@ export class StripeService { cancel_url: redirectUrl, payment_method_types: ['card', 'paypal'], }); - const stripeSession: StripeSession = { - sessionId: session.id, - userId: user.id, - purchaseId: purchase.id, - }; - this.stripeSessions.push(stripeSession); return { - sessionUrl: session.url, + sessionId: session.id, + sessionUrl: session.url!, }; } - public async getStripeSessionForCustomer(user: User) { - const stripeSession = this.stripeSessions.find( - (session) => session.userId === user.id, - ); - if (!stripeSession) { - throw new NotFoundException(); - } - - const session = await this.stripe.checkout.sessions.retrieve( - stripeSession.sessionId, - ); + public async getStripeSessionFromId(sessionId: string) { + const session = await this.stripe.checkout.sessions.retrieve(sessionId); return { isPaid: session.payment_status === 'paid', - purchaseId: stripeSession.purchaseId, }; } } diff --git a/src/service/user.service.ts b/src/service/user.service.ts index c6a228b..dba4e6c 100644 --- a/src/service/user.service.ts +++ b/src/service/user.service.ts @@ -1,46 +1,46 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { User } from 'src/dto/user'; +import { Repository } from 'typeorm'; @Injectable() export class UserService { - private readonly users: User[] = []; private readonly logger = new Logger(UserService.name); - constructor() {} + constructor(@InjectRepository(User) private userRepo: Repository) {} - public getUserByName(name: string): User { + public async getUserByName(name: string) { this.logger.debug(`Get user by name: ${name}`); - const user = this.users.find((u) => u.name === name); + const user = await this.userRepo.findOne({ where: { name: name } }); if (!user) { throw new NotFoundException(); } return user; } - public getUserById(userId: number): User { + public async getUserById(userId: string) { this.logger.debug(`Get user by id: ${userId}`); - const user = this.users.find((u) => u.id === userId); + const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException(); } return user; } - public createUser(name: string, password: string): User { + public async createUser(name: string, password: string) { try { - this.getUserByName(name); + await this.getUserByName(name); throw new NotFoundException(); } catch (e) { if (!(e instanceof NotFoundException)) throw e; } this.logger.debug(`Creating user with name: ${name}`); - const user: User = { - id: this.users.length, + const user: User = this.userRepo.create({ name, password, - }; - this.users.push(user); - return user; + purchases: [], + }); + return this.userRepo.save(user); } }