Browse Source

Merge remote-tracking branch 'origin/main' into feature/extend-holdings-endpoint-for-cash

pull/5650/head
KenTandrian 6 days ago
parent
commit
c53fae11b7
  1. 14
      .config/prisma.ts
  2. 23
      CHANGELOG.md
  3. 4
      Dockerfile
  4. 9
      apps/api/src/app/endpoints/ai/ai.service.ts
  5. 4
      apps/api/src/app/export/export.controller.ts
  6. 19
      apps/api/src/app/export/export.service.ts
  7. 12
      apps/api/src/app/import/import.service.ts
  8. 3
      apps/api/src/app/order/create-order.dto.ts
  9. 4
      apps/api/src/app/order/order.service.ts
  10. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  11. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  12. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  13. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  14. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  15. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  16. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  17. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  18. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  19. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  20. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  21. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  22. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  23. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  24. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  25. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  26. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  27. 11
      apps/api/src/app/subscription/subscription.controller.ts
  28. 62
      apps/api/src/app/subscription/subscription.service.ts
  29. 3
      apps/api/src/dependencies.ts
  30. 8
      apps/api/src/models/rules/liquidity/buying-power.ts
  31. 70
      apps/client/src/app/components/admin-users/admin-users.component.ts
  32. 12
      apps/client/src/app/components/admin-users/admin-users.html
  33. 7
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  34. 7
      apps/client/src/app/components/user-detail-dialog/interfaces/interfaces.ts
  35. 7
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss
  36. 52
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  37. 32
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  38. 7
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  39. 14
      apps/client/src/app/services/data.service.ts
  40. 6
      apps/client/src/app/services/import-activities.service.ts
  41. 2
      libs/common/src/lib/interfaces/index.ts
  42. 3
      libs/common/src/lib/interfaces/responses/create-stripe-checkout-session-response.interface.ts
  43. 8
      libs/common/src/lib/interfaces/responses/export-response.interface.ts
  44. 6
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  45. 5
      libs/ui/src/lib/activities-table/activities-table.component.html
  46. 255
      package-lock.json
  47. 8
      package.json
  48. 14
      prisma.config.ts

14
.config/prisma.ts

@ -0,0 +1,14 @@
import { defineConfig } from '@prisma/config';
import { config } from 'dotenv';
import { expand } from 'dotenv-expand';
import { join } from 'node:path';
expand(config({ quiet: true }));
export default defineConfig({
migrations: {
path: join(__dirname, '..', 'prisma', 'migrations'),
seed: `node ${join(__dirname, '..', 'prisma', 'seed.mts')}`
},
schema: join(__dirname, '..', 'prisma', 'schema.prisma')
});

23
CHANGELOG.md

@ -5,7 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 2.211.0-beta.0 - 2025-10-24
### Added
- Extended the export functionality by the user account’s performance calculation type
- Added a user detail dialog to the users section of the admin control panel
### Changed
- Localized the number formatting in the static portfolio analysis rule: _Liquidity_ (Buying Power)
- Moved the _Prisma Configuration File_ from `prisma.config.ts` to `.config/prisma.ts`
- Upgraded `prisma` from version `6.17.1` to `6.18.0`
- Upgraded `tablemark` from version `3.1.0` to `4.1.0`
### Fixed
- Fixed the style in the footer row of the accounts table
- Fixed the rendering of names and symbols for custom assets in the import activities dialog
## 2.210.1 - 2025-10-22
### Added
@ -68,7 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed the deprecated endpoint `GET api/v1/portfolio/position/:dataSource/:symbol`
- Removed the deprecated endpoint `PUT api/v1/portfolio/position/:dataSource/:symbol/tags`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.16.1` to `6.16.3`
- Upgraded `prisma` from version `6.16.1` to `6.17.1`
### Fixed

4
Dockerfile

@ -13,11 +13,11 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
# Only add basic files without the application itself to avoid rebuilding
# layers when files (package.json etc.) have not changed
COPY ./.config .config/
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./package-lock.json package-lock.json
COPY ./prisma.config.ts prisma.config.ts
COPY ./prisma/schema.prisma prisma/
RUN npm install
@ -44,7 +44,7 @@ WORKDIR /ghostfolio/dist/apps/api
COPY ./package-lock.json /ghostfolio/dist/apps/api/
RUN npm install
COPY prisma.config.ts /ghostfolio/dist/apps/api/
COPY .config /ghostfolio/dist/apps/api/.config/
COPY prisma /ghostfolio/dist/apps/api/prisma/
# Overwrite the generated package.json with the original one to ensure having

9
apps/api/src/app/endpoints/ai/ai.service.ts

@ -10,7 +10,7 @@ import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai';
import tablemark, { ColumnDescriptor } from 'tablemark';
import type { ColumnDescriptor } from 'tablemark';
@Injectable()
export class AiService {
@ -92,6 +92,13 @@ export class AiService {
}
);
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
const holdingsTableString = tablemark(holdingsTableRows, {
columns: holdingsTableColumns
});

4
apps/api/src/app/export/export.controller.ts

@ -48,8 +48,8 @@ export class ExportController {
return this.exportService.export({
activityIds,
filters,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
userId: this.request.user.id,
userSettings: this.request.user.settings.settings
});
}
}

19
apps/api/src/app/export/export.service.ts

@ -3,7 +3,11 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { ExportResponse, Filter } from '@ghostfolio/common/interfaces';
import {
ExportResponse,
Filter,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client';
@ -21,13 +25,13 @@ export class ExportService {
public async export({
activityIds,
filters,
userCurrency,
userId
userId,
userSettings
}: {
activityIds?: string[];
filters?: Filter[];
userCurrency: string;
userId: string;
userSettings: UserSettings;
}): Promise<ExportResponse> {
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
return type;
@ -36,11 +40,11 @@ export class ExportService {
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
userCurrency: userSettings?.baseCurrency,
withExcludedAccountsAndActivities: true
});
@ -244,7 +248,10 @@ export class ExportService {
}
),
user: {
settings: { currency: userCurrency }
settings: {
currency: userSettings?.baseCurrency,
performanceCalculationType: userSettings?.performanceCalculationType
}
}
};
}

12
apps/api/src/app/import/import.service.ts

@ -539,6 +539,7 @@ export class ImportService {
connectOrCreate: {
create: {
dataSource,
name,
symbol,
currency: assetProfile.currency,
userId: dataSource === 'MANUAL' ? user.id : undefined
@ -746,10 +747,19 @@ export class ImportService {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
// Skip asset profile validation for FEE, INTEREST, and LIABILITY
// as these activity types don't require asset profiles
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = {
currency,
dataSource,
symbol
symbol,
name: assetProfileInImport?.name
};
continue;

3
apps/api/src/app/order/create-order.dto.ts

@ -44,7 +44,8 @@ export class CreateOrderDto {
customCurrency?: string;
@IsEnum(DataSource)
dataSource: DataSource;
@IsOptional() // Optional for type FEE, INTEREST, and LIABILITY (default data source is resolved in the backend)
dataSource?: DataSource;
@IsISO8601()
@Validate(IsAfter1970Constraint)

4
apps/api/src/app/order/order.service.ts

@ -129,7 +129,7 @@ export class OrderService {
const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL';
let name: string;
let name = data.SymbolProfile.connectOrCreate.create.name;
let symbol: string;
if (
@ -142,7 +142,7 @@ export class OrderService {
symbol = data.SymbolProfile.connectOrCreate.create.symbol;
} else {
// Create custom asset profile
name = data.SymbolProfile.connectOrCreate.create.symbol;
name = name ?? data.SymbolProfile.connectOrCreate.create.symbol;
symbol = uuidv4();
}

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -20,7 +20,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -20,7 +20,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -20,7 +20,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -20,7 +20,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -23,7 +23,6 @@ import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -34,7 +33,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -44,7 +42,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

4
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -21,7 +21,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -32,7 +31,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -42,7 +40,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
@ -53,7 +50,6 @@ jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -23,7 +23,6 @@ import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -34,7 +33,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -44,7 +42,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -23,7 +23,6 @@ import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -34,7 +33,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -44,7 +42,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -20,7 +20,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

4
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -21,7 +21,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -32,7 +31,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -42,7 +40,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
@ -53,7 +50,6 @@ jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -20,7 +20,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -28,7 +28,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -38,7 +37,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -20,7 +20,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -15,7 +15,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -26,7 +25,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -36,7 +34,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -23,7 +23,6 @@ import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -34,7 +33,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -44,7 +42,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -23,7 +23,6 @@ import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -34,7 +33,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -44,7 +42,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -20,7 +20,6 @@ import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
@ -31,7 +30,6 @@ jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
@ -41,7 +39,6 @@ jest.mock(
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})

11
apps/api/src/app/subscription/subscription.controller.ts

@ -5,7 +5,10 @@ import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_COUPONS
} from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import {
Coupon,
CreateStripeCheckoutSessionResponse
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -111,11 +114,11 @@ export class SubscriptionController {
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createCheckoutSession(
public createStripeCheckoutSession(
@Body() { couponId, priceId }: { couponId?: string; priceId: string }
) {
): Promise<CreateStripeCheckoutSessionResponse> {
try {
return this.subscriptionService.createCheckoutSession({
return this.subscriptionService.createStripeCheckoutSession({
couponId,
priceId,
user: this.request.user

62
apps/api/src/app/subscription/subscription.service.ts

@ -6,7 +6,10 @@ import {
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { SubscriptionOffer } from '@ghostfolio/common/interfaces';
import {
CreateStripeCheckoutSessionResponse,
SubscriptionOffer
} from '@ghostfolio/common/interfaces';
import {
SubscriptionOfferKey,
UserWithSettings
@ -38,7 +41,7 @@ export class SubscriptionService {
}
}
public async createCheckoutSession({
public async createStripeCheckoutSession({
couponId,
priceId,
user
@ -46,7 +49,7 @@ export class SubscriptionService {
couponId?: string;
priceId: string;
user: UserWithSettings;
}) {
}): Promise<CreateStripeCheckoutSessionResponse> {
const subscriptionOffers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer;
} =
@ -58,33 +61,34 @@ export class SubscriptionService {
}
);
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.settings.settings.language
}/account`,
client_reference_id: user.id,
line_items: [
{
price: priceId,
quantity: 1
}
],
locale:
(user.settings?.settings
?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
DEFAULT_LANGUAGE_CODE,
metadata: subscriptionOffer
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
const stripeCheckoutSessionCreateParams: Stripe.Checkout.SessionCreateParams =
{
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.settings.settings.language
}/account`,
client_reference_id: user.id,
line_items: [
{
price: priceId,
quantity: 1
}
],
locale:
(user.settings?.settings
?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
DEFAULT_LANGUAGE_CODE,
metadata: subscriptionOffer
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) {
checkoutSessionCreateParams.discounts = [
stripeCheckoutSessionCreateParams.discounts = [
{
coupon: couponId
}
@ -92,7 +96,7 @@ export class SubscriptionService {
}
const session = await this.stripe.checkout.sessions.create(
checkoutSessionCreateParams
stripeCheckoutSessionCreateParams
);
return {

3
apps/api/src/dependencies.ts

@ -0,0 +1,3 @@
// Dependencies required by .config/prisma.ts in Docker container
import 'dotenv';
import 'dotenv-expand';

8
apps/api/src/models/rules/liquidity/buying-power.ts

@ -40,7 +40,9 @@ export class BuyingPower extends Rule<Settings> {
languageCode: this.getLanguageCode(),
placeholders: {
baseCurrency: ruleSettings.baseCurrency,
thresholdMin: ruleSettings.thresholdMin
thresholdMin: ruleSettings.thresholdMin.toLocaleString(
ruleSettings.locale
)
}
}),
value: false
@ -53,7 +55,9 @@ export class BuyingPower extends Rule<Settings> {
languageCode: this.getLanguageCode(),
placeholders: {
baseCurrency: ruleSettings.baseCurrency,
thresholdMin: ruleSettings.thresholdMin
thresholdMin: ruleSettings.thresholdMin.toLocaleString(
ruleSettings.locale
)
}
}),
value: true

70
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -19,6 +19,7 @@ import {
ViewChild
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
import {
MatPaginator,
@ -26,6 +27,7 @@ import {
PageEvent
} from '@angular/material/paginator';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import {
differenceInSeconds,
@ -37,8 +39,10 @@ import {
contractOutline,
ellipsisHorizontal,
keyOutline,
personOutline,
trashOutline
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -49,6 +53,8 @@ import { AdminService } from '../../services/admin.service';
import { DataService } from '../../services/data.service';
import { ImpersonationStorageService } from '../../services/impersonation-storage.service';
import { UserService } from '../../services/user/user.service';
import { UserDetailDialogParams } from '../user-detail-dialog/interfaces/interfaces';
import { GfUserDetailDialogComponent } from '../user-detail-dialog/user-detail-dialog.component';
@Component({
imports: [
@ -71,6 +77,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
public dataSource = new MatTableDataSource<AdminUsers['users'][0]>();
public defaultDateFormat: string;
public deviceType: string;
public displayedColumns: string[] = [];
public getEmojiFlag = getEmojiFlag;
public hasPermissionForSubscription: boolean;
@ -87,11 +94,16 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
@ -121,6 +133,14 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
];
}
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['userDetailDialog'] && params['userId']) {
this.openUserDetailDialog(params['userId']);
}
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -138,7 +158,13 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
}
});
addIcons({ contractOutline, ellipsisHorizontal, keyOutline, trashOutline });
addIcons({
contractOutline,
ellipsisHorizontal,
keyOutline,
personOutline,
trashOutline
});
}
public ngOnInit() {
@ -161,6 +187,12 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
return '';
}
public onChangePage(page: PageEvent) {
this.fetchUsers({
pageIndex: page.pageIndex
});
}
public onDeleteUser(aId: string) {
this.notificationService.confirm({
confirmFn: () => {
@ -212,9 +244,9 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
window.location.reload();
}
public onChangePage(page: PageEvent) {
this.fetchUsers({
pageIndex: page.pageIndex
public onOpenUserDetailDialog(userId: string) {
this.router.navigate([], {
queryParams: { userId, userDetailDialog: true }
});
}
@ -245,4 +277,34 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
}
private openUserDetailDialog(userId: string) {
const userData = this.dataSource.data.find(({ id }) => {
return id === userId;
});
if (!userData) {
this.router.navigate(['.'], { relativeTo: this.route });
return;
}
const dialogRef = this.dialog.open(GfUserDetailDialogComponent, {
autoFocus: false,
data: {
userData,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
} as UserDetailDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '60vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchUsers();
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

12
apps/client/src/app/components/admin-users/admin-users.html

@ -216,6 +216,15 @@
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #userMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onOpenUserDetailDialog(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="person-outline" />
<span i18n>View Details</span>
</span>
</button>
@if (hasPermissionToImpersonateAllUsers) {
<button mat-menu-item (click)="onImpersonateUser(element.id)">
<span class="align-items-center d-flex">
@ -255,8 +264,9 @@
></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
class="mat-mdc-row"
class="cursor-pointer mat-mdc-row"
mat-row
(click)="onOpenUserDetailDialog(row.id)"
></tr>
</table>
</div>

7
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -108,7 +108,10 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
public onCheckout() {
this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
.createStripeCheckoutSession({
couponId: this.couponId,
priceId: this.priceId
})
.pipe(
catchError((error) => {
this.notificationService.alert({
@ -117,7 +120,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
throw error;
}),
switchMap(({ sessionId }: { sessionId: string }) => {
switchMap(({ sessionId }) => {
return this.stripeService.redirectToCheckout({ sessionId });
})
)

7
apps/client/src/app/components/user-detail-dialog/interfaces/interfaces.ts

@ -0,0 +1,7 @@
import { AdminUsers } from '@ghostfolio/common/interfaces';
export interface UserDetailDialogParams {
deviceType: string;
locale: string;
userData: AdminUsers['users'][0];
}

7
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss

@ -0,0 +1,7 @@
:host {
display: block;
.mat-mdc-dialog-content {
max-height: unset;
}
}

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

@ -0,0 +1,52 @@
import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
CUSTOM_ELEMENTS_SCHEMA,
Inject,
OnDestroy
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { UserDetailDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' },
imports: [
CommonModule,
GfDialogFooterComponent,
GfDialogHeaderComponent,
GfValueComponent,
MatButtonModule,
MatDialogModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-user-detail-dialog',
styleUrls: ['./user-detail-dialog.component.scss'],
templateUrl: './user-detail-dialog.html'
})
export class GfUserDetailDialogComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
public dialogRef: MatDialogRef<GfUserDetailDialogComponent>
) {}
public onClose() {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

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

@ -0,0 +1,32 @@
<gf-dialog-header
position="center"
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
/>
<div class="flex-grow-1" mat-dialog-content>
<div class="container p-0">
<div class="mb-3 row">
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="data.userData.id"
>User ID</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isDate]="true"
[locale]="data.locale"
[value]="data.userData.createdAt"
>Registration Date</gf-value
>
</div>
</div>
</div>
</div>
<gf-dialog-footer
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
/>

7
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -134,9 +134,12 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
public onCheckout() {
this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
.createStripeCheckoutSession({
couponId: this.couponId,
priceId: this.priceId
})
.pipe(
switchMap(({ sessionId }: { sessionId: string }) => {
switchMap(({ sessionId }) => {
return this.stripeService.redirectToCheckout({ sessionId });
}),
catchError((error) => {

14
apps/client/src/app/services/data.service.ts

@ -31,6 +31,7 @@ import {
AssetResponse,
BenchmarkMarketDataDetailsResponse,
BenchmarkResponse,
CreateStripeCheckoutSessionResponse,
DataProviderHealthResponse,
ExportResponse,
Filter,
@ -168,17 +169,20 @@ export class DataService {
return params;
}
public createCheckoutSession({
public createStripeCheckoutSession({
couponId,
priceId
}: {
couponId?: string;
priceId: string;
}) {
return this.http.post('/api/v1/subscription/stripe/checkout-session', {
couponId,
priceId
});
return this.http.post<CreateStripeCheckoutSessionResponse>(
'/api/v1/subscription/stripe/checkout-session',
{
couponId,
priceId
}
);
}
public fetchAccount(aAccountId: string) {

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

@ -76,12 +76,8 @@ export class ImportActivitiesService {
updateAccountBalance: false
});
if (
dataSource === DataSource.MANUAL &&
!['FEE', 'INTEREST', 'LIABILITY'].includes(type)
) {
if (dataSource === DataSource.MANUAL) {
// Create synthetic asset profile for MANUAL data source
// (except for FEE, INTEREST, and LIABILITY which don't require asset profiles)
assetProfiles.push({
currency,
symbol,

2
libs/common/src/lib/interfaces/index.ts

@ -43,6 +43,7 @@ import type { ApiKeyResponse } from './responses/api-key-response.interface';
import type { AssetResponse } from './responses/asset-response.interface';
import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { CreateStripeCheckoutSessionResponse } from './responses/create-stripe-checkout-session-response.interface';
import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface';
import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface';
import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface';
@ -100,6 +101,7 @@ export {
BenchmarkProperty,
BenchmarkResponse,
Coupon,
CreateStripeCheckoutSessionResponse,
DataEnhancerHealthResponse,
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse,

3
libs/common/src/lib/interfaces/responses/create-stripe-checkout-session-response.interface.ts

@ -0,0 +1,3 @@
export interface CreateStripeCheckoutSessionResponse {
sessionId: string;
}

8
libs/common/src/lib/interfaces/responses/export-response.interface.ts

@ -9,6 +9,7 @@ import {
import { AccountBalance } from '../account-balance.interface';
import { MarketData } from '../market-data.interface';
import { UserSettings } from '../user-settings.interface';
export interface ExportResponse {
accounts: (Omit<Account, 'createdAt' | 'updatedAt' | 'userId'> & {
@ -36,5 +37,10 @@ export interface ExportResponse {
};
platforms: Platform[];
tags: Omit<Tag, 'userId'>[];
user: { settings: { currency: string } };
user: {
settings: {
currency: UserSettings['baseCurrency'];
performanceCalculationType: UserSettings['performanceCalculationType'];
};
};
}

6
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -320,11 +320,7 @@
</button>
</mat-menu>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>

5
libs/ui/src/lib/activities-table/activities-table.component.html

@ -143,7 +143,10 @@
}
</div>
</div>
@if (!isUUID(element.SymbolProfile?.symbol)) {
@if (
element.SymbolProfile?.dataSource !== 'MANUAL' &&
!isUUID(element.SymbolProfile?.symbol)
) {
<div>
<small class="text-muted">{{
element.SymbolProfile?.symbol | gfSymbol

255
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.209.0",
"version": "2.211.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.209.0",
"version": "2.211.0-beta.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -43,7 +43,7 @@
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.17.1",
"@prisma/client": "6.18.0",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0",
@ -92,7 +92,7 @@
"rxjs": "7.8.1",
"stripe": "18.5.0",
"svgmap": "2.12.2",
"tablemark": "3.1.0",
"tablemark": "4.1.0",
"twitter-api-v2": "1.23.0",
"uuid": "11.1.0",
"yahoo-finance2": "3.10.0",
@ -151,7 +151,7 @@
"nx": "21.5.1",
"prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.17.1",
"prisma": "6.18.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",
@ -11983,9 +11983,9 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.1.tgz",
"integrity": "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.18.0.tgz",
"integrity": "sha512-jnL2I9gDnPnw4A+4h5SuNn8Gc+1mL1Z79U/3I9eE2gbxJG1oSA+62ByPW4xkeDgwE0fqMzzpAZ7IHxYnLZ4iQA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -12005,66 +12005,66 @@
}
},
"node_modules/@prisma/config": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.1.tgz",
"integrity": "sha512-fs8wY6DsvOCzuiyWVckrVs1LOcbY4LZNz8ki4uUIQ28jCCzojTGqdLhN2Jl5lDnC1yI8/gNIKpsWDM8pLhOdwA==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.18.0.tgz",
"integrity": "sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.16.12",
"effect": "3.18.4",
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.1.tgz",
"integrity": "sha512-Vf7Tt5Wh9XcndpbmeotuqOMLWPTjEKCsgojxXP2oxE1/xYe7PtnP76hsouG9vis6fctX+TxgmwxTuYi/+xc7dQ==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.1.tgz",
"integrity": "sha512-D95Ik3GYZkqZ8lSR4EyFOJ/tR33FcYRP8kK61o+WMsyD10UfJwd7+YielflHfKwiGodcqKqoraWw8ElAgMDbPw==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz",
"integrity": "sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.17.1",
"@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"@prisma/fetch-engine": "6.17.1",
"@prisma/get-platform": "6.17.1"
"@prisma/debug": "6.18.0",
"@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
"@prisma/fetch-engine": "6.18.0",
"@prisma/get-platform": "6.18.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac.tgz",
"integrity": "sha512-17140E3huOuD9lMdJ9+SF/juOf3WR3sTJMVyyenzqUPbuH+89nPhSWcrY+Mf7tmSs6HvaO+7S+HkELinn6bhdg==",
"version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f.tgz",
"integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.1.tgz",
"integrity": "sha512-AYZiHOs184qkDMiTeshyJCtyL4yERkjfTkJiSJdYuSfc24m94lTNL5+GFinZ6vVz+ktX4NJzHKn1zIFzGTWrWg==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz",
"integrity": "sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.17.1",
"@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"@prisma/get-platform": "6.17.1"
"@prisma/debug": "6.18.0",
"@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
"@prisma/get-platform": "6.18.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.1.tgz",
"integrity": "sha512-AKEn6fsfz0r482S5KRDFlIGEaq9wLNcgalD1adL+fPcFFblIKs1sD81kY/utrHdqKuVC6E1XSRpegDK3ZLL4Qg==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.18.0.tgz",
"integrity": "sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.17.1"
"@prisma/debug": "6.18.0"
}
},
"node_modules/@redis/client": {
@ -17595,6 +17595,12 @@
"node": ">=8"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"license": "MIT"
},
"node_modules/char-regex": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
@ -21166,9 +21172,9 @@
"license": "MIT"
},
"node_modules/effect": {
"version": "3.16.12",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz",
"integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==",
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@ -23695,15 +23701,6 @@
"node": ">= 0.4"
}
},
"node_modules/get-stdin": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz",
"integrity": "sha512-jZV7n6jGE3Gt7fgSTJoz91Ak5MuTLwMwkoYdjxuJ/AmjIsE1UC03y/IWkZCQGEvVNS9qoRNwy5BCqxImv0FVeA==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@ -32021,6 +32018,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
@ -32924,6 +32922,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"lower-case": "^2.0.2",
@ -35802,15 +35801,15 @@
}
},
"node_modules/prisma": {
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.1.tgz",
"integrity": "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.18.0.tgz",
"integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.17.1",
"@prisma/engines": "6.17.1"
"@prisma/config": "6.18.0",
"@prisma/engines": "6.18.0"
},
"bin": {
"prisma": "build/index.js"
@ -37691,17 +37690,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/sentence-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz",
"integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==",
"license": "MIT",
"dependencies": {
"no-case": "^3.0.4",
"tslib": "^2.0.3",
"upper-case-first": "^2.0.2"
}
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
@ -38351,19 +38339,6 @@
"wbuf": "^1.7.3"
}
},
"node_modules/split-text-to-chunks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/split-text-to-chunks/-/split-text-to-chunks-1.0.0.tgz",
"integrity": "sha512-HLtEwXK/T4l7QZSJ/kOSsZC0o5e2Xg3GzKKFxm0ZexJXw0Bo4CaEl39l7MCSRHk9EOOL5jT8JIDjmhTtcoe6lQ==",
"license": "MIT",
"dependencies": {
"get-stdin": "^5.0.1",
"minimist": "^1.2.0"
},
"bin": {
"wordwrap": "cli.js"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@ -39133,16 +39108,114 @@
}
},
"node_modules/tablemark": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tablemark/-/tablemark-3.1.0.tgz",
"integrity": "sha512-IwO6f0SEzp1Z+zqz/7ANUmeEac4gaNlknWyj/S9aSg11wZmWYnLeyI/xXvEOU88BYUIf8y30y0wxB58xIKrVlQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/tablemark/-/tablemark-4.1.0.tgz",
"integrity": "sha512-B3LDjbDo+ac+D5RwkBOPZZ6ua8716KdT+6NO3DKOCHJq0ezE6vV2r92rjrC1ci2H+ocuysl5ytf1T0QqV65yoA==",
"license": "MIT",
"dependencies": {
"sentence-case": "^3.0.4",
"split-text-to-chunks": "^1.0.0"
"ansi-regex": "^6.2.2",
"change-case": "^5.4.4",
"string-width": "^8.1.0",
"wordwrapjs": "^5.1.0",
"wrap-ansi": "^9.0.2"
},
"engines": {
"node": ">=14.16"
"node": ">=20"
}
},
"node_modules/tablemark/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/tablemark/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/tablemark/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/tablemark/node_modules/string-width": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tablemark/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/tablemark/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/tablemark/node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tapable": {
@ -40624,15 +40697,6 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/upper-case-first": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz",
"integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -42036,6 +42100,15 @@
"node": ">=0.10.0"
}
},
"node_modules/wordwrapjs": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz",
"integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",

8
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.209.0",
"version": "2.211.0-beta.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -89,7 +89,7 @@
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.17.1",
"@prisma/client": "6.18.0",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0",
@ -138,7 +138,7 @@
"rxjs": "7.8.1",
"stripe": "18.5.0",
"svgmap": "2.12.2",
"tablemark": "3.1.0",
"tablemark": "4.1.0",
"twitter-api-v2": "1.23.0",
"uuid": "11.1.0",
"yahoo-finance2": "3.10.0",
@ -197,7 +197,7 @@
"nx": "21.5.1",
"prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.17.1",
"prisma": "6.18.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",

14
prisma.config.ts

@ -1,14 +0,0 @@
import { config } from 'dotenv';
import { expand } from 'dotenv-expand';
import { join } from 'node:path';
import { defineConfig } from 'prisma/config';
expand(config({ quiet: true }));
export default defineConfig({
migrations: {
path: join('prisma', 'migrations'),
seed: `node ${join('prisma', 'seed.mts')}`
},
schema: join('prisma', 'schema.prisma')
});
Loading…
Cancel
Save