From e2063d7d4a7dfef95965b8da7cb57c17dc198be3 Mon Sep 17 00:00:00 2001 From: soundsng Date: Tue, 23 Jun 2026 02:36:50 +0100 Subject: [PATCH] feat: build promo codes and discount backend module --- backend/package-lock.json | 77 ++++++- backend/src/app.module.ts | 4 +- .../src/bookings/entities/booking.entity.ts | 6 + backend/src/dashboard/dashboard.service.ts | 3 +- backend/src/email/email.service.ts | 7 +- .../dto/update-hub-settings.dto.ts | 5 +- .../hub-settings/hub-settings.controller.ts | 14 +- backend/src/payments/payments.controller.ts | 12 +- backend/src/payments/payments.module.ts | 2 + .../providers/handle-webhook.provider.ts | 18 ++ .../providers/soroban-escrow.provider.ts | 52 +++-- .../promo-codes/dto/create-promo-code.dto.ts | 51 +++++ .../promo-codes/dto/update-promo-code.dto.ts | 4 + .../dto/validate-promo-code.dto.ts | 14 ++ .../entities/promo-code-usage.entity.ts | 48 +++++ .../promo-codes/entities/promo-code.entity.ts | 55 +++++ .../promo-codes/enums/discount-type.enum.ts | 4 + .../src/promo-codes/promo-codes.controller.ts | 54 +++++ backend/src/promo-codes/promo-codes.module.ts | 15 ++ .../src/promo-codes/promo-codes.service.ts | 191 ++++++++++++++++++ backend/src/utils/soroban-types.ts | 2 +- .../src/visitors/dto/create-visitor.dto.ts | 2 +- backend/src/visitors/dto/visitor-query.dto.ts | 2 +- .../src/visitors/enums/visitor-status.enum.ts | 2 +- backend/src/visitors/visitors.controller.ts | 7 +- backend/src/visitors/visitors.module.ts | 2 +- backend/src/visitors/visitors.service.ts | 38 +++- 27 files changed, 636 insertions(+), 55 deletions(-) create mode 100644 backend/src/promo-codes/dto/create-promo-code.dto.ts create mode 100644 backend/src/promo-codes/dto/update-promo-code.dto.ts create mode 100644 backend/src/promo-codes/dto/validate-promo-code.dto.ts create mode 100644 backend/src/promo-codes/entities/promo-code-usage.entity.ts create mode 100644 backend/src/promo-codes/entities/promo-code.entity.ts create mode 100644 backend/src/promo-codes/enums/discount-type.enum.ts create mode 100644 backend/src/promo-codes/promo-codes.controller.ts create mode 100644 backend/src/promo-codes/promo-codes.module.ts create mode 100644 backend/src/promo-codes/promo-codes.service.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index ca120470..68a30ab2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1093,6 +1093,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -3646,6 +3647,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -3692,6 +3694,7 @@ "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -3772,6 +3775,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", + "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -4289,6 +4293,20 @@ } } }, + "node_modules/@nestjs/schematics/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@nestjs/schematics/node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -4315,6 +4333,34 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@nestjs/schematics/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -4421,6 +4467,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", "license": "MIT", + "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -5616,6 +5663,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5642,6 +5690,7 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5779,6 +5828,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5997,6 +6047,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -6393,6 +6444,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6452,6 +6504,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6803,6 +6856,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", @@ -7166,6 +7220,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7270,6 +7325,7 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", + "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -7308,6 +7364,7 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", + "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -7542,6 +7599,7 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -7597,13 +7655,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -8813,6 +8873,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8869,6 +8930,7 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10727,6 +10789,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11789,6 +11852,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -13253,6 +13317,7 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "license": "MIT-0", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -13598,6 +13663,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -13752,6 +13818,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -14020,6 +14087,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14702,6 +14770,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -14773,6 +14842,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -16006,6 +16076,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16183,6 +16254,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -16407,6 +16479,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16829,6 +16902,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -17146,6 +17220,7 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 93578b48..fde2aedf 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,6 +22,7 @@ import { NotificationsModule } from './notifications/notifications.module'; import { WorkspaceTrackingModule } from './workspace-tracking/workspace-tracking.module'; import { HubSettingsModule } from './hub-settings/hub-settings.module'; import { VisitorsModule } from './visitors/visitors.module'; +import { PromoCodesModule } from './promo-codes/promo-codes.module'; @Module({ imports: [ @@ -103,6 +104,7 @@ import { VisitorsModule } from './visitors/visitors.module'; WorkspaceTrackingModule, HubSettingsModule, VisitorsModule, + PromoCodesModule, ], controllers: [AppController], providers: [ @@ -117,4 +119,4 @@ import { VisitorsModule } from './visitors/visitors.module'; }, ], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/backend/src/bookings/entities/booking.entity.ts b/backend/src/bookings/entities/booking.entity.ts index 63f5e32e..52d101a7 100644 --- a/backend/src/bookings/entities/booking.entity.ts +++ b/backend/src/bookings/entities/booking.entity.ts @@ -63,6 +63,12 @@ export class Booking { @Column({ nullable: true }) sorobanEscrowId: string; + @Column({ nullable: true, type: 'uuid' }) + appliedPromoCodeId: string | null; + + @Column({ type: 'int', nullable: true }) + promoDiscountApplied: number | null; + @Column({ default: false }) reminderSent: boolean; diff --git a/backend/src/dashboard/dashboard.service.ts b/backend/src/dashboard/dashboard.service.ts index cfde8ea9..b65c53a9 100644 --- a/backend/src/dashboard/dashboard.service.ts +++ b/backend/src/dashboard/dashboard.service.ts @@ -32,7 +32,8 @@ export class DashboardService { return { totalMembers, verifiedMembers, - activeWorkspaces: await this.adminAnalyticsProvider.getActiveWorkspacesCount(), + activeWorkspaces: + await this.adminAnalyticsProvider.getActiveWorkspacesCount(), deskOccupancy: Math.min( Math.round((verifiedMembers / Math.max(totalMembers, 1)) * 100), 100, diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index 2fee5320..13d532bf 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -238,7 +238,10 @@ export class EmailService { ]); } - async sendVisitorCheckInEmail(host: User, visitor: Visitor): Promise { + async sendVisitorCheckInEmail( + host: User, + visitor: Visitor, + ): Promise { const html = this.compileTemplate('visitor-check-in', { hostName: host.fullName, visitorName: visitor.fullName, @@ -246,4 +249,4 @@ export class EmailService { }); return this.send(host.email, 'Your visitor has arrived', html); } -} \ No newline at end of file +} diff --git a/backend/src/hub-settings/dto/update-hub-settings.dto.ts b/backend/src/hub-settings/dto/update-hub-settings.dto.ts index f1d3ddd2..8794e221 100644 --- a/backend/src/hub-settings/dto/update-hub-settings.dto.ts +++ b/backend/src/hub-settings/dto/update-hub-settings.dto.ts @@ -106,7 +106,10 @@ export class UpdateHubSettingsDto { @Length(1, 100) timezone?: string; - @ApiPropertyOptional({ example: 7.5, description: 'Tax rate as a percentage (0–100)' }) + @ApiPropertyOptional({ + example: 7.5, + description: 'Tax rate as a percentage (0–100)', + }) @IsNumber() @IsOptional() @Min(0) diff --git a/backend/src/hub-settings/hub-settings.controller.ts b/backend/src/hub-settings/hub-settings.controller.ts index f5949e6f..738dfe5b 100644 --- a/backend/src/hub-settings/hub-settings.controller.ts +++ b/backend/src/hub-settings/hub-settings.controller.ts @@ -20,7 +20,10 @@ export class HubSettingsController { @Get() @Public() @ApiOperation({ summary: 'Get current hub configuration (public)' }) - @ApiResponse({ status: 200, description: 'Hub settings retrieved successfully.' }) + @ApiResponse({ + status: 200, + description: 'Hub settings retrieved successfully.', + }) getSettings() { return this.hubSettingsService.getSettings(); } @@ -29,8 +32,13 @@ export class HubSettingsController { @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) @UseGuards(RolesGuard) @ApiBearerAuth() - @ApiOperation({ summary: 'Update hub configuration (Admin / Super-admin only)' }) - @ApiResponse({ status: 200, description: 'Hub settings updated successfully.' }) + @ApiOperation({ + summary: 'Update hub configuration (Admin / Super-admin only)', + }) + @ApiResponse({ + status: 200, + description: 'Hub settings updated successfully.', + }) updateSettings(@Body() updateHubSettingsDto: UpdateHubSettingsDto) { return this.hubSettingsService.updateSettings(updateHubSettingsDto); } diff --git a/backend/src/payments/payments.controller.ts b/backend/src/payments/payments.controller.ts index c8c7f9bd..dc6c7c21 100644 --- a/backend/src/payments/payments.controller.ts +++ b/backend/src/payments/payments.controller.ts @@ -81,8 +81,16 @@ export class PaymentsController { @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number }) @ApiQuery({ name: 'bookingId', required: false, type: String }) - @ApiQuery({ name: 'status', required: false, enum: ['pending', 'success', 'failed', 'refunded'] }) - @ApiQuery({ name: 'provider', required: false, enum: ['paystack', 'soroban'] }) + @ApiQuery({ + name: 'status', + required: false, + enum: ['pending', 'success', 'failed', 'refunded'], + }) + @ApiQuery({ + name: 'provider', + required: false, + enum: ['paystack', 'soroban'], + }) @ApiQuery({ name: 'from', required: false, type: String }) @ApiQuery({ name: 'to', required: false, type: String }) async findAll( diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts index 894202ba..13b428ce 100644 --- a/backend/src/payments/payments.module.ts +++ b/backend/src/payments/payments.module.ts @@ -14,6 +14,7 @@ import { FindPaymentsProvider } from './providers/find-payments.provider'; import { BookingsModule } from '../bookings/bookings.module'; import { InvoicesModule } from '../invoices/invoices.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { PromoCodesModule } from '../promo-codes/promo-codes.module'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; BookingsModule, InvoicesModule, NotificationsModule, + PromoCodesModule, ], controllers: [PaymentsController], providers: [ diff --git a/backend/src/payments/providers/handle-webhook.provider.ts b/backend/src/payments/providers/handle-webhook.provider.ts index 3d174009..b8c22ae8 100644 --- a/backend/src/payments/providers/handle-webhook.provider.ts +++ b/backend/src/payments/providers/handle-webhook.provider.ts @@ -19,6 +19,7 @@ import { NotificationsService } from '../../notifications/notifications.service' import { NotificationType } from '../../notifications/enums/notification-type.enum'; import { User } from '../../users/entities/user.entity'; import { EmailService } from '../../email/email.service'; +import { PromoCodesService } from '../../promo-codes/promo-codes.service'; const LONG_TERM_PLANS = new Set([ PlanType.MONTHLY, @@ -44,6 +45,7 @@ export class HandleWebhookProvider { private readonly notificationsService: NotificationsService, private readonly emailService: EmailService, private readonly configService: ConfigService, + private readonly promoCodesService: PromoCodesService, ) {} async handle(rawBody: Buffer, signature: string): Promise { @@ -117,6 +119,22 @@ export class HandleWebhookProvider { await this.recordSorobanEscrow(payment, booking); } + // Record promo code usage atomically if one was applied + if (booking.appliedPromoCodeId) { + this.promoCodesService + .recordUsage( + booking.appliedPromoCodeId, + payment.userId, + booking.id, + booking.promoDiscountApplied ?? 0, + ) + .catch((err: Error) => { + this.logger.error( + `Failed to record promo usage for booking ${booking.id}: ${err.message}`, + ); + }); + } + // Generate invoice asynchronously — do not block payment confirmation this.invoicesService.generateForPayment(payment.id).catch((err: Error) => { this.logger.error( diff --git a/backend/src/payments/providers/soroban-escrow.provider.ts b/backend/src/payments/providers/soroban-escrow.provider.ts index 26eff567..04e6da30 100644 --- a/backend/src/payments/providers/soroban-escrow.provider.ts +++ b/backend/src/payments/providers/soroban-escrow.provider.ts @@ -88,14 +88,11 @@ export class SorobanEscrowProvider { type: xdr.SorobanCredentialsType.sorobanCredentialsSourceAccount(), address: new xdr.SorobanAddress({ type: xdr.SorobanAddressType.sorobanAddressTypeAccount(), - accountId: Keypair.fromPublicKey( - depositorAddress, - ).xdrPublicKey(), + accountId: + Keypair.fromPublicKey(depositorAddress).xdrPublicKey(), }), nonce: xdr.Int64.fromString( - ( - await this.server.getLatestLedger() - ).sequence.toString(), + (await this.server.getLatestLedger()).sequence.toString(), ), signatureExpirationLedger: (await this.server.getLatestLedger()).sequence + TTL, @@ -143,8 +140,9 @@ export class SorobanEscrowProvider { const sentTransaction = await this.server.sendTransaction(preparedTransaction); - let getTransactionResponse = - await this.server.getTransaction(sentTransaction.hash); + let getTransactionResponse = await this.server.getTransaction( + sentTransaction.hash, + ); const thirtySeconds = 30 * 1000; const startTime = Date.now(); @@ -162,14 +160,13 @@ export class SorobanEscrowProvider { } if ( - getTransactionResponse.status !== SorobanRpc.GetTransactionStatus.SUCCESS + getTransactionResponse.status !== + SorobanRpc.GetTransactionStatus.SUCCESS ) { this.logger.error( `[Soroban] createEscrow failed for booking ${bookingId}: Transaction execution failed`, ); - throw new BadGatewayException( - 'Failed to execute Soroban transaction.', - ); + throw new BadGatewayException('Failed to execute Soroban transaction.'); } return sentTransaction.hash; @@ -211,8 +208,9 @@ export class SorobanEscrowProvider { const sentTransaction = await this.server.sendTransaction(preparedTransaction); - let getTransactionResponse = - await this.server.getTransaction(sentTransaction.hash); + let getTransactionResponse = await this.server.getTransaction( + sentTransaction.hash, + ); const thirtySeconds = 30 * 1000; const startTime = Date.now(); @@ -230,14 +228,13 @@ export class SorobanEscrowProvider { } if ( - getTransactionResponse.status !== SorobanRpc.GetTransactionStatus.SUCCESS + getTransactionResponse.status !== + SorobanRpc.GetTransactionStatus.SUCCESS ) { this.logger.error( `[Soroban] releaseEscrow failed for escrow ${escrowId}: Transaction execution failed`, ); - throw new BadGatewayException( - 'Failed to execute Soroban transaction.', - ); + throw new BadGatewayException('Failed to execute Soroban transaction.'); } return sentTransaction.hash; @@ -279,8 +276,9 @@ export class SorobanEscrowProvider { const sentTransaction = await this.server.sendTransaction(preparedTransaction); - let getTransactionResponse = - await this.server.getTransaction(sentTransaction.hash); + let getTransactionResponse = await this.server.getTransaction( + sentTransaction.hash, + ); const thirtySeconds = 30 * 1000; const startTime = Date.now(); @@ -298,14 +296,13 @@ export class SorobanEscrowProvider { } if ( - getTransactionResponse.status !== SorobanRpc.GetTransactionStatus.SUCCESS + getTransactionResponse.status !== + SorobanRpc.GetTransactionStatus.SUCCESS ) { this.logger.error( `[Soroban] refundEscrow failed for escrow ${escrowId}: Transaction execution failed`, ); - throw new BadGatewayException( - 'Failed to execute Soroban transaction.', - ); + throw new BadGatewayException('Failed to execute Soroban transaction.'); } return sentTransaction.hash; @@ -345,10 +342,7 @@ export class SorobanEscrowProvider { const simulatedTransaction = await this.server.simulateTransaction(preparedTransaction); - if ( - !simulatedTransaction.result || - !simulatedTransaction.result.retval - ) { + if (!simulatedTransaction.result || !simulatedTransaction.result.retval) { this.logger.error( `[Soroban] getEscrowStatus failed for escrow ${escrowId}: Invalid simulation response`, ); @@ -373,4 +367,4 @@ export class SorobanEscrowProvider { private async getSourceAccount() { return await this.server.getAccount(this.source.publicKey()); } -} \ No newline at end of file +} diff --git a/backend/src/promo-codes/dto/create-promo-code.dto.ts b/backend/src/promo-codes/dto/create-promo-code.dto.ts new file mode 100644 index 00000000..485718ac --- /dev/null +++ b/backend/src/promo-codes/dto/create-promo-code.dto.ts @@ -0,0 +1,51 @@ +import { + IsArray, + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { DiscountType } from '../enums/discount-type.enum'; +import { WorkspaceType } from '../../workspaces/enums/workspace-type.enum'; + +export class CreatePromoCodeDto { + @IsString() + @IsNotEmpty() + code: string; + + @IsEnum(DiscountType) + discountType: DiscountType; + + @IsInt() + @Min(1) + @Max(100000000) + discountValue: number; + + @IsOptional() + @IsInt() + @Min(1) + maxUses?: number; + + @IsOptional() + @IsInt() + @Min(0) + minBookingAmount?: number; + + @IsOptional() + @IsArray() + @IsEnum(WorkspaceType, { each: true }) + applicableWorkspaceTypes?: WorkspaceType[]; + + @IsOptional() + @IsDateString() + expiresAt?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/promo-codes/dto/update-promo-code.dto.ts b/backend/src/promo-codes/dto/update-promo-code.dto.ts new file mode 100644 index 00000000..0e23eb4b --- /dev/null +++ b/backend/src/promo-codes/dto/update-promo-code.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreatePromoCodeDto } from './create-promo-code.dto'; + +export class UpdatePromoCodeDto extends PartialType(CreatePromoCodeDto) {} diff --git a/backend/src/promo-codes/dto/validate-promo-code.dto.ts b/backend/src/promo-codes/dto/validate-promo-code.dto.ts new file mode 100644 index 00000000..d6e44fe9 --- /dev/null +++ b/backend/src/promo-codes/dto/validate-promo-code.dto.ts @@ -0,0 +1,14 @@ +import { IsInt, IsNotEmpty, IsString, IsUUID, Min } from 'class-validator'; + +export class ValidatePromoCodeDto { + @IsString() + @IsNotEmpty() + code: string; + + @IsUUID() + workspaceId: string; + + @IsInt() + @Min(0) + bookingAmount: number; +} diff --git a/backend/src/promo-codes/entities/promo-code-usage.entity.ts b/backend/src/promo-codes/entities/promo-code-usage.entity.ts new file mode 100644 index 00000000..1c842759 --- /dev/null +++ b/backend/src/promo-codes/entities/promo-code-usage.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { PromoCode } from './promo-code.entity'; +import { User } from '../../users/entities/user.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; + +@Entity('promo_code_usages') +@Unique(['promoCodeId', 'userId']) +export class PromoCodeUsage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + promoCodeId: string; + + @ManyToOne(() => PromoCode, (promoCode) => promoCode.usages, { + onDelete: 'RESTRICT', + }) + @JoinColumn({ name: 'promoCodeId' }) + promoCode: PromoCode; + + @Column('uuid') + userId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column('uuid') + bookingId: string; + + @ManyToOne(() => Booking, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'bookingId' }) + booking: Booking; + + @Column({ type: 'int' }) + discountApplied: number; + + @CreateDateColumn() + usedAt: Date; +} diff --git a/backend/src/promo-codes/entities/promo-code.entity.ts b/backend/src/promo-codes/entities/promo-code.entity.ts new file mode 100644 index 00000000..f2803c91 --- /dev/null +++ b/backend/src/promo-codes/entities/promo-code.entity.ts @@ -0,0 +1,55 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { DiscountType } from '../enums/discount-type.enum'; +import { WorkspaceType } from '../../workspaces/enums/workspace-type.enum'; +import { PromoCodeUsage } from './promo-code-usage.entity'; + +@Entity('promo_codes') +@Index(['code'], { unique: true }) +export class PromoCode { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + code: string; + + @Column({ type: 'enum', enum: DiscountType }) + discountType: DiscountType; + + @Column({ type: 'int' }) + discountValue: number; + + @Column({ type: 'int', nullable: true }) + maxUses: number | null; + + @Column({ type: 'int', default: 0 }) + usedCount: number; + + @Column({ type: 'int', default: 0 }) + minBookingAmount: number; + + @Column({ type: 'simple-array', nullable: true }) + applicableWorkspaceTypes: WorkspaceType[] | null; + + @Column({ type: 'timestamptz', nullable: true }) + expiresAt: Date | null; + + @Column({ default: true }) + isActive: boolean; + + @OneToMany(() => PromoCodeUsage, (usage) => usage.promoCode) + usages: PromoCodeUsage[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/promo-codes/enums/discount-type.enum.ts b/backend/src/promo-codes/enums/discount-type.enum.ts new file mode 100644 index 00000000..c34ca459 --- /dev/null +++ b/backend/src/promo-codes/enums/discount-type.enum.ts @@ -0,0 +1,4 @@ +export enum DiscountType { + PERCENTAGE = 'percentage', + FIXED_AMOUNT = 'fixed_amount', +} diff --git a/backend/src/promo-codes/promo-codes.controller.ts b/backend/src/promo-codes/promo-codes.controller.ts new file mode 100644 index 00000000..e81c145d --- /dev/null +++ b/backend/src/promo-codes/promo-codes.controller.ts @@ -0,0 +1,54 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, +} from '@nestjs/common'; +import { PromoCodesService } from './promo-codes.service'; +import { CreatePromoCodeDto } from './dto/create-promo-code.dto'; +import { UpdatePromoCodeDto } from './dto/update-promo-code.dto'; +import { ValidatePromoCodeDto } from './dto/validate-promo-code.dto'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { CurrentUser } from '../auth/decorators/current.user.decorators'; +import { UserRole } from '../users/enums/userRoles.enum'; +import { User } from '../users/entities/user.entity'; + +@Controller('promo-codes') +export class PromoCodesController { + constructor(private readonly promoCodesService: PromoCodesService) {} + + @Post() + @Roles(UserRole.ADMIN) + create(@Body() dto: CreatePromoCodeDto) { + return this.promoCodesService.create(dto); + } + + @Get() + @Roles(UserRole.ADMIN) + findAll() { + return this.promoCodesService.findAll(); + } + + @Patch(':id') + @Roles(UserRole.ADMIN) + update(@Param('id') id: string, @Body() dto: UpdatePromoCodeDto) { + return this.promoCodesService.update(id, dto); + } + + @Delete(':id') + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id') id: string): Promise { + return this.promoCodesService.remove(id); + } + + @Post('validate') + validate(@Body() dto: ValidatePromoCodeDto, @CurrentUser() user: User) { + return this.promoCodesService.validate(dto, user.id); + } +} diff --git a/backend/src/promo-codes/promo-codes.module.ts b/backend/src/promo-codes/promo-codes.module.ts new file mode 100644 index 00000000..7ffea756 --- /dev/null +++ b/backend/src/promo-codes/promo-codes.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PromoCode } from './entities/promo-code.entity'; +import { PromoCodeUsage } from './entities/promo-code-usage.entity'; +import { PromoCodesService } from './promo-codes.service'; +import { PromoCodesController } from './promo-codes.controller'; +import { Workspace } from '../workspaces/entities/workspace.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([PromoCode, PromoCodeUsage, Workspace])], + controllers: [PromoCodesController], + providers: [PromoCodesService], + exports: [PromoCodesService], +}) +export class PromoCodesModule {} diff --git a/backend/src/promo-codes/promo-codes.service.ts b/backend/src/promo-codes/promo-codes.service.ts new file mode 100644 index 00000000..8ea73922 --- /dev/null +++ b/backend/src/promo-codes/promo-codes.service.ts @@ -0,0 +1,191 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { PromoCode } from './entities/promo-code.entity'; +import { PromoCodeUsage } from './entities/promo-code-usage.entity'; +import { CreatePromoCodeDto } from './dto/create-promo-code.dto'; +import { UpdatePromoCodeDto } from './dto/update-promo-code.dto'; +import { ValidatePromoCodeDto } from './dto/validate-promo-code.dto'; +import { DiscountType } from './enums/discount-type.enum'; +import { Workspace } from '../workspaces/entities/workspace.entity'; + +export interface ValidatePromoCodeResponse { + valid: boolean; + discountType?: DiscountType; + discountValue?: number; + finalAmount?: number; + message: string; +} + +@Injectable() +export class PromoCodesService { + constructor( + @InjectRepository(PromoCode) + private readonly promoCodesRepository: Repository, + @InjectRepository(PromoCodeUsage) + private readonly usagesRepository: Repository, + @InjectRepository(Workspace) + private readonly workspacesRepository: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(dto: CreatePromoCodeDto): Promise { + const code = dto.code.toUpperCase(); + const existing = await this.promoCodesRepository.findOne({ + where: { code }, + }); + if (existing) { + throw new ConflictException(`Promo code "${code}" already exists`); + } + + const promoCode = this.promoCodesRepository.create({ + ...dto, + code, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + maxUses: dto.maxUses ?? null, + minBookingAmount: dto.minBookingAmount ?? 0, + applicableWorkspaceTypes: dto.applicableWorkspaceTypes ?? null, + }); + + return this.promoCodesRepository.save(promoCode); + } + + findAll(): Promise { + return this.promoCodesRepository.find({ order: { createdAt: 'DESC' } }); + } + + async findOne(id: string): Promise { + const promoCode = await this.promoCodesRepository.findOne({ + where: { id }, + }); + if (!promoCode) { + throw new NotFoundException(`Promo code "${id}" not found`); + } + return promoCode; + } + + async update(id: string, dto: UpdatePromoCodeDto): Promise { + const promoCode = await this.findOne(id); + if (dto.code) { + dto.code = dto.code.toUpperCase(); + } + Object.assign(promoCode, dto); + if (dto.expiresAt) { + promoCode.expiresAt = new Date(dto.expiresAt); + } + return this.promoCodesRepository.save(promoCode); + } + + async remove(id: string): Promise { + const promoCode = await this.findOne(id); + await this.promoCodesRepository.remove(promoCode); + } + + async validate( + dto: ValidatePromoCodeDto, + userId: string, + ): Promise { + const code = dto.code.toUpperCase(); + const promoCode = await this.promoCodesRepository.findOne({ + where: { code }, + }); + + if (!promoCode) { + return { valid: false, message: 'Invalid promo code' }; + } + if (!promoCode.isActive) { + return { valid: false, message: 'Promo code is inactive' }; + } + if (promoCode.expiresAt && promoCode.expiresAt < new Date()) { + return { valid: false, message: 'Promo code has expired' }; + } + if ( + promoCode.maxUses !== null && + promoCode.usedCount >= promoCode.maxUses + ) { + return { valid: false, message: 'Promo code usage limit reached' }; + } + if (dto.bookingAmount < promoCode.minBookingAmount) { + return { + valid: false, + message: `Minimum booking amount is ₦${(promoCode.minBookingAmount / 100).toFixed(2)}`, + }; + } + + if ( + promoCode.applicableWorkspaceTypes && + promoCode.applicableWorkspaceTypes.length > 0 + ) { + const workspace = await this.workspacesRepository.findOne({ + where: { id: dto.workspaceId }, + }); + if (!workspace) { + return { valid: false, message: 'Workspace not found' }; + } + if (!promoCode.applicableWorkspaceTypes.includes(workspace.type)) { + return { + valid: false, + message: 'Promo code is not applicable to this workspace type', + }; + } + } + + const alreadyUsed = await this.usagesRepository.findOne({ + where: { promoCodeId: promoCode.id, userId }, + }); + if (alreadyUsed) { + return { + valid: false, + message: 'You have already used this promo code', + }; + } + + const discount = + promoCode.discountType === DiscountType.PERCENTAGE + ? Math.floor((dto.bookingAmount * promoCode.discountValue) / 100) + : promoCode.discountValue; + + const finalAmount = Math.max(0, dto.bookingAmount - discount); + + return { + valid: true, + discountType: promoCode.discountType, + discountValue: promoCode.discountValue, + finalAmount, + message: 'Promo code applied successfully', + }; + } + + async recordUsage( + promoCodeId: string, + userId: string, + bookingId: string, + discountApplied: number, + ): Promise { + const alreadyRecorded = await this.usagesRepository.findOne({ + where: { promoCodeId, userId }, + }); + if (alreadyRecorded) return; + + await this.dataSource.transaction(async (manager) => { + const usage = manager.create(PromoCodeUsage, { + promoCodeId, + userId, + bookingId, + discountApplied, + }); + await manager.save(usage); + await manager.increment(PromoCode, { id: promoCodeId }, 'usedCount', 1); + }); + } + + async findByCode(code: string): Promise { + return this.promoCodesRepository.findOne({ + where: { code: code.toUpperCase() }, + }); + } +} diff --git a/backend/src/utils/soroban-types.ts b/backend/src/utils/soroban-types.ts index 5627fe0c..1b38d22f 100644 --- a/backend/src/utils/soroban-types.ts +++ b/backend/src/utils/soroban-types.ts @@ -26,4 +26,4 @@ export function mapScValToescrowStatus(scVal: any): string { default: return 'Unknown'; } -} \ No newline at end of file +} diff --git a/backend/src/visitors/dto/create-visitor.dto.ts b/backend/src/visitors/dto/create-visitor.dto.ts index fa52411e..1d51fb0e 100644 --- a/backend/src/visitors/dto/create-visitor.dto.ts +++ b/backend/src/visitors/dto/create-visitor.dto.ts @@ -31,4 +31,4 @@ export class CreateVisitorDto { @IsDateString() @IsNotEmpty() expectedDate: string; -} \ No newline at end of file +} diff --git a/backend/src/visitors/dto/visitor-query.dto.ts b/backend/src/visitors/dto/visitor-query.dto.ts index f1d4e741..e65af64c 100644 --- a/backend/src/visitors/dto/visitor-query.dto.ts +++ b/backend/src/visitors/dto/visitor-query.dto.ts @@ -14,4 +14,4 @@ export class VisitorQueryDto extends PaginationQueryDto { @IsOptional() @IsUUID() hostMemberId?: string; -} \ No newline at end of file +} diff --git a/backend/src/visitors/enums/visitor-status.enum.ts b/backend/src/visitors/enums/visitor-status.enum.ts index 873ec38c..4f6947e3 100644 --- a/backend/src/visitors/enums/visitor-status.enum.ts +++ b/backend/src/visitors/enums/visitor-status.enum.ts @@ -3,4 +3,4 @@ export enum VisitorStatus { CHECKED_IN = 'checked_in', CHECKED_OUT = 'checked_out', CANCELLED = 'cancelled', -} \ No newline at end of file +} diff --git a/backend/src/visitors/visitors.controller.ts b/backend/src/visitors/visitors.controller.ts index a323d038..620f2de9 100644 --- a/backend/src/visitors/visitors.controller.ts +++ b/backend/src/visitors/visitors.controller.ts @@ -26,7 +26,10 @@ export class VisitorsController { constructor(private readonly visitorsService: VisitorsService) {} @Post() - create(@Body() createVisitorDto: CreateVisitorDto, @CurrentUser() user: User) { + create( + @Body() createVisitorDto: CreateVisitorDto, + @CurrentUser() user: User, + ) { return this.visitorsService.create(createVisitorDto, user); } @@ -57,4 +60,4 @@ export class VisitorsController { remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User) { return this.visitorsService.remove(id, user); } -} \ No newline at end of file +} diff --git a/backend/src/visitors/visitors.module.ts b/backend/src/visitors/visitors.module.ts index 6d48e831..3f82e2c5 100644 --- a/backend/src/visitors/visitors.module.ts +++ b/backend/src/visitors/visitors.module.ts @@ -10,4 +10,4 @@ import { EmailModule } from '../email/email.module'; controllers: [VisitorsController], providers: [VisitorsService], }) -export class VisitorsModule {} \ No newline at end of file +export class VisitorsModule {} diff --git a/backend/src/visitors/visitors.service.ts b/backend/src/visitors/visitors.service.ts index b12e7617..8344c8c4 100644 --- a/backend/src/visitors/visitors.service.ts +++ b/backend/src/visitors/visitors.service.ts @@ -17,7 +17,10 @@ export class VisitorsService { private readonly emailService: EmailService, ) {} - async create(createVisitorDto: CreateVisitorDto, hostMember: User): Promise { + async create( + createVisitorDto: CreateVisitorDto, + hostMember: User, + ): Promise { const visitor = this.visitorRepository.create({ ...createVisitorDto, hostMemberId: hostMember.id, @@ -44,9 +47,19 @@ export class VisitorsService { } const offset = (page - 1) * limit; - query.leftJoinAndSelect('visitor.hostMember', 'hostMember') - .select(['visitor', 'hostMember.id', 'hostMember.firstname', 'hostMember.lastname', 'hostMember.email']); - const [visitors, total] = await query.skip(offset).take(limit).getManyAndCount(); + query + .leftJoinAndSelect('visitor.hostMember', 'hostMember') + .select([ + 'visitor', + 'hostMember.id', + 'hostMember.firstname', + 'hostMember.lastname', + 'hostMember.email', + ]); + const [visitors, total] = await query + .skip(offset) + .take(limit) + .getManyAndCount(); return { data: visitors, @@ -71,7 +84,10 @@ export class VisitorsService { } const offset = (page - 1) * limit; - const [visitors, total] = await query.skip(offset).take(limit).getManyAndCount(); + const [visitors, total] = await query + .skip(offset) + .take(limit) + .getManyAndCount(); return { data: visitors, @@ -82,7 +98,10 @@ export class VisitorsService { } async findOne(id: string): Promise { - const visitor = await this.visitorRepository.findOne({ where: { id }, relations: ['hostMember'] }); + const visitor = await this.visitorRepository.findOne({ + where: { id }, + relations: ['hostMember'], + }); if (!visitor) { throw new NotFoundException(`Visitor with ID "${id}" not found`); } @@ -100,7 +119,10 @@ export class VisitorsService { const updatedVisitor = await this.visitorRepository.save(visitor); // Send email to host member - await this.emailService.sendVisitorCheckInEmail(visitor.hostMember, updatedVisitor); + await this.emailService.sendVisitorCheckInEmail( + visitor.hostMember, + updatedVisitor, + ); return updatedVisitor; } @@ -125,4 +147,4 @@ export class VisitorsService { await this.visitorRepository.delete(id); } -} \ No newline at end of file +}