diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index ef6753894..1de70e588 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -31,7 +31,6 @@ export class AdminController { public constructor( private readonly adminService: AdminService, private readonly dataGatheringService: DataGatheringService, - private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 0eb345f63..8dbd54ee9 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -1,4 +1,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { PROPERTY_COUPONS } from '@ghostfolio/common/config'; +import { Coupon } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -14,6 +17,7 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { Response } from 'express'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { SubscriptionService } from './subscription.service'; @@ -22,10 +26,49 @@ import { SubscriptionService } from './subscription.service'; export class SubscriptionController { public constructor( private readonly configurationService: ConfigurationService, + private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly subscriptionService: SubscriptionService ) {} + @Post('redeem-coupon') + @UseGuards(AuthGuard('jwt')) + public async redeemCoupon( + @Body() { couponCode }: { couponCode: string }, + @Res() res: Response + ) { + if (!this.request.user) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const coupons = + ((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ?? + []; + + const isValid = coupons.some((coupon) => { + return coupon.code === couponCode; + }); + + if (!isValid) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + // TODO: Add subscription + + res.status(StatusCodes.OK); + + return res.json({ + message: getReasonPhrase(StatusCodes.OK), + statusCode: StatusCodes.OK + }); + } + @Get('stripe/callback') public async stripeCallback(@Req() req, @Res() res) { await this.subscriptionService.createSubscription( diff --git a/apps/api/src/app/subscription/subscription.module.ts b/apps/api/src/app/subscription/subscription.module.ts index 48671550c..95d16fb4d 100644 --- a/apps/api/src/app/subscription/subscription.module.ts +++ b/apps/api/src/app/subscription/subscription.module.ts @@ -1,12 +1,13 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; import { SubscriptionController } from './subscription.controller'; import { SubscriptionService } from './subscription.service'; @Module({ - imports: [], + imports: [PropertyModule], controllers: [SubscriptionController], providers: [ConfigurationService, PrismaService, SubscriptionService], exports: [SubscriptionService] diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index 51105632b..178f46a7b 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -6,11 +6,12 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_DATE_FORMAT, + PROPERTY_COUPONS, PROPERTY_CURRENCIES, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SYSTEM_MESSAGE } from '@ghostfolio/common/config'; -import { InfoItem, User } from '@ghostfolio/common/interfaces'; +import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { differenceInSeconds, @@ -28,11 +29,13 @@ import { takeUntil } from 'rxjs/operators'; templateUrl: './admin-overview.html' }) export class AdminOverviewComponent implements OnDestroy, OnInit { + public coupons: Coupon[]; public customCurrencies: string[]; public dataGatheringInProgress: boolean; public dataGatheringProgress: number; public defaultDateFormat = DEFAULT_DATE_FORMAT; public exchangeRates: { label1: string; label2: string; value: number }[]; + public hasPermissionForSubscription: boolean; public hasPermissionForSystemMessage: boolean; public hasPermissionToToggleReadOnlyMode: boolean; public info: InfoItem; @@ -61,6 +64,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { if (state?.user) { this.user = state.user; + this.hasPermissionForSubscription = hasPermission( + this.info.globalPermissions, + permissions.enableSubscription + ); + this.hasPermissionForSystemMessage = hasPermission( this.info.globalPermissions, permissions.enableSystemMessage @@ -96,6 +104,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { return ''; } + public onAddCoupon() { + const coupons = [...this.coupons, { code: this.generateCouponCode(16) }]; + this.putCoupons(coupons); + } + public onAddCurrency() { const currency = prompt('Please add a currency:'); @@ -105,6 +118,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { } } + public onDeleteCoupon(aCouponCode: string) { + const confirmation = confirm('Do you really want to delete this coupon?'); + + if (confirmation) { + const coupons = this.coupons.filter((coupon) => { + return coupon.code !== aCouponCode; + }); + this.putCoupons(coupons); + } + } + public onDeleteCurrency(aCurrency: string) { const confirmation = confirm('Do you really want to delete this currency?'); @@ -185,6 +209,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { transactionCount, userCount }) => { + this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? []; this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; this.dataGatheringProgress = dataGatheringProgress; this.exchangeRates = exchangeRates; @@ -210,6 +235,32 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { ); } + private generateCouponCode(aLength: number) { + const characters = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'; + let couponCode = ''; + + for (let i = 0; i < aLength; i++) { + couponCode += characters.charAt( + Math.floor(Math.random() * characters.length) + ); + } + + return couponCode; + } + + private putCoupons(aCoupons: Coupon[]) { + this.dataService + .putAdminSetting(PROPERTY_COUPONS, { + value: JSON.stringify(aCoupons) + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + setTimeout(() => { + window.location.reload(); + }, 300); + }); + } + private putCurrencies(aCurrencies: string[]) { this.dataService .putAdminSetting(PROPERTY_CURRENCIES, { diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index 80bbf1ab6..04f8ba4ec 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -142,7 +142,7 @@ class="mr-1" name="information-circle-outline" > - Set System Message + Set Message @@ -156,6 +156,27 @@ > +
+
Coupons
+
+
+ {{ coupon.code }} + +
+
+ +
+
+
diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index ea0cf8d2e..1df29e7ec 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -14,15 +14,14 @@ import { TextOnlySnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { InfoItem } from '@ghostfolio/common/interfaces'; import { StatusCodes } from 'http-status-codes'; import { Observable, throwError } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; - @Injectable() export class HttpResponseInterceptor implements HttpInterceptor { public info: InfoItem; diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 244c57cce..779e5068f 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -18,6 +18,7 @@ import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { StatusCodes } from 'http-status-codes'; import { DeviceDetectorService } from 'ngx-device-detector'; import { StripeService } from 'ngx-stripe'; import { EMPTY, Subject } from 'rxjs'; @@ -185,6 +186,26 @@ export class AccountPageComponent implements OnDestroy, OnInit { }); } + public onRedeemCoupon() { + const couponCode = prompt('Please add your coupon code:'); + + if (couponCode) { + this.dataService + .redeemCoupon(couponCode) + .pipe( + takeUntil(this.unsubscribeSubject), + catchError(() => { + // TODO: show error notification + + return EMPTY; + }) + ) + .subscribe(() => { + // TODO: show success notification + }); + } + } + public onRestrictedViewChange(aEvent: MatSlideToggleChange) { this.dataService .putUserSetting({ isRestrictedView: aEvent.checked }) diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index 9ffd2159d..dba3c366f 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -47,6 +47,14 @@ {{ price }} per year + diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index b15dd1723..9ba5ca669 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -253,4 +253,10 @@ export class DataService { public putUserSettings(aData: UpdateUserSettingsDto) { return this.http.put(`/api/user/settings`, aData); } + + public redeemCoupon(couponCode: string) { + return this.http.post('/api/subscription/redeem-coupon', { + couponCode + }); + } } diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 31076cccd..d9acd3e0b 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -30,6 +30,7 @@ export const warnColorRgb = { export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; +export const PROPERTY_COUPONS = 'COUPONS'; export const PROPERTY_CURRENCIES = 'CURRENCIES'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING'; diff --git a/libs/common/src/lib/interfaces/coupon.interface.ts b/libs/common/src/lib/interfaces/coupon.interface.ts new file mode 100644 index 000000000..3caa218e6 --- /dev/null +++ b/libs/common/src/lib/interfaces/coupon.interface.ts @@ -0,0 +1,3 @@ +export interface Coupon { + code: string; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 3192ece9f..d9bcc3a8b 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -3,6 +3,7 @@ import { Accounts } from './accounts.interface'; import { AdminData } from './admin-data.interface'; import { AdminMarketDataDetails } from './admin-market-data-details.interface'; import { AdminMarketData } from './admin-market-data.interface'; +import { Coupon } from './coupon.interface'; import { Export } from './export.interface'; import { InfoItem } from './info-item.interface'; import { PortfolioChart } from './portfolio-chart.interface'; @@ -27,6 +28,7 @@ export { AdminData, AdminMarketData, AdminMarketDataDetails, + Coupon, Export, InfoItem, PortfolioChart,