Browse Source

Merge remote-tracking branch 'origin/main' into task/allocations-page-type-safety

pull/7076/head
KenTandrian 4 weeks ago
parent
commit
f98e02f4e7
  1. 4
      CHANGELOG.md
  2. 2
      README.md
  3. 18
      apps/api/src/app/admin/admin.service.ts
  4. 12
      apps/client/src/app/components/footer/footer.component.html
  5. 41
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  6. 96
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  7. 172
      apps/client/src/app/services/import-activities.service.ts
  8. 1
      libs/common/src/lib/interfaces/admin-user.interface.ts

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added the Korean (`ko`) language to the footer
### Changed ### Changed
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles` - Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`

2
README.md

@ -362,7 +362,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public, including you. Ghostfolio is **100% free** and **open source**. We support an active and healthy community and welcome contributions from everyone, including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.

18
apps/api/src/app/admin/admin.service.ts

@ -240,6 +240,24 @@ export class AdminService {
throw new NotFoundException(`User with ID ${id} not found`); throw new NotFoundException(`User with ID ${id} not found`);
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const subscriptions = await this.prismaService.subscription.findMany({
orderBy: {
expiresAt: 'desc'
},
where: {
userId: id
}
});
user.subscriptions = subscriptions.map((subscription) => {
return {
...subscription,
price: subscription.price ?? 0
};
});
}
return user; return user;
} }

12
apps/client/src/app/components/footer/footer.component.html

@ -141,13 +141,11 @@
<li> <li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a> <a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li> </li>
<!-- <li>
<li> <a href="../ko" title="Ghostfolio in Korean (한국어)"
<a href="../ko" title="Ghostfolio in Korean (한국어)" >Korean (한국어)</a
>Korean (한국어)</a >
> </li>
</li>
-->
<li> <li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a> <a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li> </li>

41
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts

@ -1,5 +1,6 @@
import { getSum } from '@ghostfolio/common/helper';
import { AdminUserResponse } from '@ghostfolio/common/interfaces'; import { AdminUserResponse } from '@ghostfolio/common/interfaces';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
@ -16,7 +17,11 @@ import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Subscription } from '@prisma/client';
import { Big } from 'big.js';
import { differenceInDays } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { ellipsisVertical } from 'ionicons/icons'; import { ellipsisVertical } from 'ionicons/icons';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
@ -35,7 +40,8 @@ import {
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatMenuModule MatMenuModule,
MatTableModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-user-detail-dialog', selector: 'gf-user-detail-dialog',
@ -43,18 +49,29 @@ import {
templateUrl: './user-detail-dialog.html' templateUrl: './user-detail-dialog.html'
}) })
export class GfUserDetailDialogComponent implements OnInit { export class GfUserDetailDialogComponent implements OnInit {
public baseCurrency: string;
public subscriptionsDataSource = new MatTableDataSource<Subscription>();
public subscriptionsDisplayedColumns = [
'createdAt',
'type',
'price',
'expiresAt'
];
public user: AdminUserResponse; public user: AdminUserResponse;
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
public dialogRef: MatDialogRef< public dialogRef: MatDialogRef<
GfUserDetailDialogComponent, GfUserDetailDialogComponent,
UserDetailDialogResult UserDetailDialogResult
> >
) { ) {
this.baseCurrency = this.dataService.fetchInfo().baseCurrency;
addIcons({ addIcons({
ellipsisVertical ellipsisVertical
}); });
@ -74,6 +91,8 @@ export class GfUserDetailDialogComponent implements OnInit {
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
this.subscriptionsDataSource.data = this.user.subscriptions ?? [];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
@ -85,6 +104,24 @@ export class GfUserDetailDialogComponent implements OnInit {
}); });
} }
public getSum() {
return getSum(
this.subscriptionsDataSource.data.map(({ price }) => {
return new Big(price);
})
).toNumber();
}
public getType({ createdAt, expiresAt, price }: Subscription) {
if (price) {
return $localize`Paid`;
}
return differenceInDays(expiresAt, createdAt) <= 90
? $localize`Trial`
: $localize`Coupon`;
}
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }

96
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html

@ -124,6 +124,102 @@
> >
</div> </div>
</div> </div>
@if (subscriptionsDataSource.data.length > 0) {
<div class="row">
<div class="col">
<h2 class="h6" i18n>Subscriptions</h2>
<div class="overflow-x-auto">
<table
class="gf-table w-100"
mat-table
[dataSource]="subscriptionsDataSource"
>
<ng-container matColumnDef="createdAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Created</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-value
[isDate]="true"
[locale]="data.locale"
[value]="element.createdAt"
/>
</td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>
Total
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ getType(element) }}
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="price">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<ng-container i18n>Price</ng-container>
</th>
<td
*matCellDef="let element"
class="px-1 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="data.locale"
[unit]="baseCurrency"
[value]="element.price"
/>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="data.locale"
[unit]="baseCurrency"
[value]="getSum()"
/>
</td>
</ng-container>
<ng-container matColumnDef="expiresAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Expires</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-value
[isDate]="true"
[locale]="data.locale"
[value]="element.expiresAt"
/>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<tr
*matHeaderRowDef="subscriptionsDisplayedColumns"
mat-header-row
></tr>
<tr
*matRowDef="let row; columns: subscriptionsDisplayedColumns"
mat-row
></tr>
<tr
*matFooterRowDef="subscriptionsDisplayedColumns"
mat-footer-row
></tr>
</table>
</div>
</div>
</div>
}
} }
</div> </div>
</div> </div>

172
apps/client/src/app/services/import-activities.service.ts

@ -8,12 +8,11 @@ import { parseDate as parseDateHelper } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces'; import { Activity } from '@ghostfolio/common/interfaces';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { Account, DataSource, Type as ActivityType } from '@prisma/client'; import { Account, DataSource, Type as ActivityType } from '@prisma/client';
import { isFinite } from 'lodash'; import { isFinite, isNumber, isString } from 'lodash';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { EMPTY } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -35,7 +34,7 @@ export class ImportActivitiesService {
'value' 'value'
]; ];
public constructor(private http: HttpClient) {} private readonly http = inject(HttpClient);
public async importCsv({ public async importCsv({
fileContent, fileContent,
@ -49,7 +48,7 @@ export class ImportActivitiesService {
activities: Activity[]; activities: Activity[];
assetProfiles: CreateAssetProfileWithMarketDataDto[]; assetProfiles: CreateAssetProfileWithMarketDataDto[];
}> { }> {
const content = csvToJson(fileContent, { const content = csvToJson<Record<string, unknown>>(fileContent, {
dynamicTyping: true, dynamicTyping: true,
header: true, header: true,
skipEmptyLines: true skipEmptyLines: true
@ -83,22 +82,22 @@ export class ImportActivitiesService {
assetProfiles.push({ assetProfiles.push({
currency, currency,
symbol, symbol,
assetClass: null, assetClass: undefined,
assetSubClass: null, assetSubClass: undefined,
comment: null, comment: undefined,
countries: [], countries: [],
cusip: null, cusip: undefined,
dataSource: DataSource.MANUAL, dataSource: DataSource.MANUAL,
figi: null, figi: undefined,
figiComposite: null, figiComposite: undefined,
figiShareClass: null, figiShareClass: undefined,
holdings: [], holdings: [],
isActive: true, isActive: true,
isin: null, isin: undefined,
marketData: [], marketData: [],
name: symbol, name: symbol,
sectors: [], sectors: [],
url: null url: undefined
}); });
} }
} }
@ -126,7 +125,7 @@ export class ImportActivitiesService {
}): Promise<{ }): Promise<{
activities: Activity[]; activities: Activity[];
}> { }> {
return new Promise((resolve, reject) => { return firstValueFrom(
this.postImport( this.postImport(
{ {
accounts, accounts,
@ -136,18 +135,7 @@ export class ImportActivitiesService {
}, },
isDryRun isDryRun
) )
.pipe( );
catchError((error) => {
reject(error);
return EMPTY;
})
)
.subscribe({
next: (data) => {
resolve(data);
}
});
});
} }
public importSelectedActivities({ public importSelectedActivities({
@ -163,11 +151,9 @@ export class ImportActivitiesService {
}): Promise<{ }): Promise<{
activities: Activity[]; activities: Activity[];
}> { }> {
const importData: CreateOrderDto[] = []; const importData = activities.map((activity) =>
this.convertToCreateOrderDto(activity)
for (const activity of activities) { );
importData.push(this.convertToCreateOrderDto(activity));
}
return this.importJson({ return this.importJson({
accounts, accounts,
@ -191,14 +177,14 @@ export class ImportActivitiesService {
updateAccountBalance updateAccountBalance
}: Activity): CreateOrderDto { }: Activity): CreateOrderDto {
return { return {
accountId,
comment,
fee, fee,
quantity, quantity,
type, type,
unitPrice, unitPrice,
updateAccountBalance, updateAccountBalance,
currency: currency ?? SymbolProfile.currency, accountId: accountId ?? undefined,
comment: comment ?? undefined,
currency: currency ?? SymbolProfile.currency ?? '',
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toString(), date: date.toString(),
symbol: SymbolProfile.symbol, symbol: SymbolProfile.symbol,
@ -208,28 +194,32 @@ export class ImportActivitiesService {
}; };
} }
private lowercaseKeys(aObject: any) { private lowercaseKeys(
return Object.keys(aObject).reduce((acc, key) => { aObject: Record<string, unknown>
acc[key.toLowerCase()] = aObject[key]; ): Record<string, unknown> {
return acc; return Object.fromEntries(
}, {}); Object.entries(aObject).map(([key, val]) => {
return [key.toLowerCase(), val];
})
);
} }
private parseAccount({ private parseAccount({
item, item,
userAccounts userAccounts
}: { }: {
item: any; item: Record<string, unknown>;
userAccounts: Account[]; userAccounts: Account[];
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.ACCOUNT_KEYS) { for (const key of ImportActivitiesService.ACCOUNT_KEYS) {
if (item[key]) { const value = item[key];
return userAccounts.find((account) => {
if (isNumber(value) || isString(value)) {
return userAccounts.find(({ id, name }) => {
return ( return (
account.id === item[key] || id === value || name?.toLowerCase() === String(value).toLowerCase()
account.name.toLowerCase() === item[key].toLowerCase()
); );
})?.id; })?.id;
} }
@ -238,12 +228,14 @@ export class ImportActivitiesService {
return undefined; return undefined;
} }
private parseComment({ item }: { item: any }) { private parseComment({ item }: { item: Record<string, unknown> }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.COMMENT_KEYS) { for (const key of ImportActivitiesService.COMMENT_KEYS) {
if (item[key]) { const value = item[key];
return item[key];
if (isNumber(value) || isString(value)) {
return String(value);
} }
} }
@ -255,15 +247,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.CURRENCY_KEYS) { for (const key of ImportActivitiesService.CURRENCY_KEYS) {
if (item[key]) { const value = item[key];
return item[key];
if (isString(value)) {
return value;
} }
} }
@ -273,12 +267,14 @@ export class ImportActivitiesService {
}; };
} }
private parseDataSource({ item }: { item: any }) { private parseDataSource({ item }: { item: Record<string, unknown> }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) { for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) {
if (item[key]) { const value = item[key];
return DataSource[item[key].toUpperCase()];
if (isString(value)) {
return DataSource[value.toUpperCase() as keyof typeof DataSource];
} }
} }
@ -290,16 +286,22 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.DATE_KEYS) { for (const key of ImportActivitiesService.DATE_KEYS) {
if (item[key]) { const value = item[key];
if (isNumber(value) || isString(value)) {
try { try {
return parseDateHelper(item[key].toString()).toISOString(); const parsedDate = parseDateHelper(String(value));
if (parsedDate) {
return parsedDate.toISOString();
}
} catch {} } catch {}
} }
} }
@ -315,15 +317,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.FEE_KEYS) { for (const key of ImportActivitiesService.FEE_KEYS) {
if (isFinite(item[key])) { const value = item[key];
return Math.abs(item[key]);
if (isNumber(value) && isFinite(value)) {
return Math.abs(value);
} }
} }
@ -338,15 +342,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.QUANTITY_KEYS) { for (const key of ImportActivitiesService.QUANTITY_KEYS) {
if (isFinite(item[key])) { const value = item[key];
return Math.abs(item[key]);
if (isNumber(value) && isFinite(value)) {
return Math.abs(value);
} }
} }
@ -361,15 +367,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.SYMBOL_KEYS) { for (const key of ImportActivitiesService.SYMBOL_KEYS) {
if (item[key]) { const value = item[key];
return item[key];
if (isNumber(value) || isString(value)) {
return String(value);
} }
} }
@ -384,15 +392,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}): ActivityType { }): ActivityType {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.TYPE_KEYS) { for (const key of ImportActivitiesService.TYPE_KEYS) {
if (item[key]) { const value = item[key];
switch (item[key].toLowerCase()) {
if (isString(value)) {
switch (value.toLowerCase()) {
case 'buy': case 'buy':
return 'BUY'; return 'BUY';
case 'dividend': case 'dividend':
@ -422,15 +432,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) { for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) {
if (isFinite(item[key])) { const value = item[key];
return Math.abs(item[key]);
if (isNumber(value) && isFinite(value)) {
return Math.abs(value);
} }
} }

1
libs/common/src/lib/interfaces/admin-user.interface.ts

@ -12,4 +12,5 @@ export interface AdminUser {
provider: Provider; provider: Provider;
role: Role; role: Role;
subscription?: Subscription; subscription?: Subscription;
subscriptions?: Subscription[];
} }

Loading…
Cancel
Save