mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
18 changed files with 799 additions and 166 deletions
@ -0,0 +1,172 @@ |
|||||
|
import { |
||||
|
DynamicModule, |
||||
|
ForwardReference, |
||||
|
RequestMethod, |
||||
|
Type |
||||
|
} from '@nestjs/common'; |
||||
|
import { |
||||
|
GUARDS_METADATA, |
||||
|
METHOD_METADATA, |
||||
|
MODULE_METADATA, |
||||
|
PATH_METADATA |
||||
|
} from '@nestjs/common/constants'; |
||||
|
import { MetadataScanner } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
import { AppModule } from './app.module'; |
||||
|
|
||||
|
// The packages are ECMAScript modules, which Jest cannot load
|
||||
|
jest.mock('@openrouter/ai-sdk-provider', () => { |
||||
|
return {}; |
||||
|
}); |
||||
|
jest.mock('ai', () => { |
||||
|
return {}; |
||||
|
}); |
||||
|
|
||||
|
type ModuleDefinition = |
||||
|
DynamicModule | ForwardReference<() => Type> | Promise<DynamicModule> | Type; |
||||
|
|
||||
|
/** |
||||
|
* The routes of the controllers which answer a request without the |
||||
|
* authentication of a user or of an API key, sorted by the request method and |
||||
|
* the path. A new route has to apply AuthGuard('api-key') or AuthGuard('jwt') |
||||
|
* (e.g. via the decorator RequiresScope) or has to be added here. |
||||
|
* |
||||
|
* The MCP transport, Bull Board and the static files are not covered, as they |
||||
|
* are not served by a controller |
||||
|
*/ |
||||
|
const PUBLIC_ROUTES = [ |
||||
|
'GET /asset/:dataSource/:symbol', |
||||
|
'GET /assets/:languageCode/site.webmanifest', |
||||
|
'GET /auth/google', |
||||
|
'GET /auth/google/callback', |
||||
|
'GET /auth/oidc', |
||||
|
'GET /auth/oidc/callback', |
||||
|
'GET /benchmarks', |
||||
|
'GET /health', |
||||
|
'GET /health/ai', |
||||
|
'GET /health/data-enhancer/:name', |
||||
|
'GET /health/data-provider/:dataSource', |
||||
|
'GET /health/liveness', |
||||
|
'GET /info', |
||||
|
'GET /logo', |
||||
|
'GET /logo/:dataSource/:symbol', |
||||
|
'GET /public/:accessId/portfolio', |
||||
|
'GET /sitemap.xml', |
||||
|
'GET /subscription/stripe/callback', |
||||
|
'POST /auth/anonymous', |
||||
|
'POST /auth/webauthn/generate-authentication-options', |
||||
|
'POST /auth/webauthn/verify-authentication', |
||||
|
'POST /user' |
||||
|
]; |
||||
|
|
||||
|
/** |
||||
|
* Gives the controllers of the module and of each module which it imports |
||||
|
*/ |
||||
|
async function getControllers( |
||||
|
moduleDefinition: ModuleDefinition, |
||||
|
visitedModules = new Set<DynamicModule | Type>() |
||||
|
): Promise<Type[]> { |
||||
|
const resolvedModule = await ('forwardRef' in moduleDefinition |
||||
|
? moduleDefinition.forwardRef() |
||||
|
: moduleDefinition); |
||||
|
|
||||
|
if (visitedModules.has(resolvedModule)) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
visitedModules.add(resolvedModule); |
||||
|
|
||||
|
const isDynamicModule = 'module' in resolvedModule; |
||||
|
|
||||
|
const controllers = [ |
||||
|
...(isDynamicModule |
||||
|
? (resolvedModule.controllers ?? []) |
||||
|
: ((Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, resolvedModule) ?? |
||||
|
[]) as Type[])) |
||||
|
]; |
||||
|
|
||||
|
const importedModules = isDynamicModule |
||||
|
? [resolvedModule.module, ...(resolvedModule.imports ?? [])] |
||||
|
: ((Reflect.getMetadata(MODULE_METADATA.IMPORTS, resolvedModule) ?? |
||||
|
[]) as ModuleDefinition[]); |
||||
|
|
||||
|
for (const importedModule of importedModules) { |
||||
|
controllers.push(...(await getControllers(importedModule, visitedModules))); |
||||
|
} |
||||
|
|
||||
|
return [...new Set(controllers)]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Gives the paths which the decorator of a controller or of a route sets |
||||
|
*/ |
||||
|
function getPaths(target: object) { |
||||
|
return [ |
||||
|
(Reflect.getMetadata(PATH_METADATA, target) ?? '') as string | string[] |
||||
|
].flat(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Gives the routes of the controller which apply neither AuthGuard('api-key') |
||||
|
* nor AuthGuard('jwt'), each as the request method and the path |
||||
|
*/ |
||||
|
function getRoutesWithoutAuthentication(controller: Type) { |
||||
|
const authenticationGuards: unknown[] = [ |
||||
|
AuthGuard('api-key'), |
||||
|
AuthGuard('jwt') |
||||
|
]; |
||||
|
const routeHandlersByName = controller.prototype as Record<string, object>; |
||||
|
|
||||
|
return new MetadataScanner() |
||||
|
.getAllMethodNames(routeHandlersByName) |
||||
|
.flatMap((methodName) => { |
||||
|
const routeHandler = routeHandlersByName[methodName]; |
||||
|
const requestMethod = Reflect.getMetadata( |
||||
|
METHOD_METADATA, |
||||
|
routeHandler |
||||
|
) as RequestMethod | undefined; |
||||
|
|
||||
|
if (requestMethod === undefined) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
const guards = [controller, routeHandler].flatMap((target) => { |
||||
|
return (Reflect.getMetadata(GUARDS_METADATA, target) ?? |
||||
|
[]) as unknown[]; |
||||
|
}); |
||||
|
|
||||
|
const hasAuthentication = guards.some((guard) => { |
||||
|
return authenticationGuards.includes(guard); |
||||
|
}); |
||||
|
|
||||
|
if (hasAuthentication) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
return getPaths(controller).flatMap((controllerPath) => { |
||||
|
return getPaths(routeHandler).map((routePath) => { |
||||
|
const path = `${controllerPath}/${routePath}` |
||||
|
.split('/') |
||||
|
.filter(Boolean) |
||||
|
.join('/'); |
||||
|
|
||||
|
return `${RequestMethod[requestMethod]} /${path}`; |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
describe('AppModule', () => { |
||||
|
it('should require the authentication for each route which is not public', async () => { |
||||
|
const controllers = await getControllers(AppModule); |
||||
|
|
||||
|
const routesWithoutAuthentication = controllers |
||||
|
.flatMap((controller) => { |
||||
|
return getRoutesWithoutAuthentication(controller); |
||||
|
}) |
||||
|
.sort(); |
||||
|
|
||||
|
expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,365 @@ |
|||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
||||
|
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
||||
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; |
||||
|
import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; |
||||
|
import { CreateOrderDto } from '@ghostfolio/common/dtos'; |
||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
Activity, |
||||
|
AssetProfileIdentifier |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { UserWithSettings } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { DataSource, SymbolProfile } from '@prisma/client'; |
||||
|
import { parseISO } from 'date-fns'; |
||||
|
|
||||
|
import { ImportService } from './import.service'; |
||||
|
|
||||
|
const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; |
||||
|
|
||||
|
describe('ImportService', () => { |
||||
|
let createActivity: jest.Mock; |
||||
|
let gatherSymbols: jest.Mock; |
||||
|
let importService: ImportService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
const configuration = { |
||||
|
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], |
||||
|
ENABLE_FEATURE_SUBSCRIPTION: false, |
||||
|
MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER |
||||
|
}; |
||||
|
|
||||
|
createActivity = jest.fn(); |
||||
|
gatherSymbols = jest.fn(); |
||||
|
|
||||
|
const validateActivities = jest.fn( |
||||
|
({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { |
||||
|
const assetProfiles: { |
||||
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>; |
||||
|
} = {}; |
||||
|
|
||||
|
for (const { currency, dataSource, symbol } of activitiesDto) { |
||||
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] ??= { |
||||
|
currency, |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
name: symbol |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return assetProfiles; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
importService = new ImportService( |
||||
|
{ |
||||
|
getAccounts: jest.fn().mockResolvedValue([]) |
||||
|
} as unknown as AccountService, |
||||
|
{ |
||||
|
createActivity, |
||||
|
getActivities: jest.fn().mockResolvedValue({ activities: [], count: 0 }) |
||||
|
} as unknown as ActivitiesService, |
||||
|
null, |
||||
|
{ |
||||
|
get: (key: keyof typeof configuration) => { |
||||
|
return configuration[key]; |
||||
|
} |
||||
|
} as unknown as ConfigurationService, |
||||
|
{ gatherSymbols } as unknown as DataGatheringService, |
||||
|
{ |
||||
|
validateActivities, |
||||
|
getDataSourceForImport: jest.fn().mockReturnValue(DataSource.MANUAL) |
||||
|
} as unknown as DataProviderService, |
||||
|
{ |
||||
|
toCurrencyAtDate: jest.fn().mockResolvedValue(0) |
||||
|
} as unknown as ExchangeRateDataService, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
{ |
||||
|
getTagsForUser: jest.fn().mockResolvedValue([]) |
||||
|
} as unknown as TagService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('import', () => { |
||||
|
it('keeps the asset profile of an investment activity after a non-investment activity', async () => { |
||||
|
mockCreatedAssetProfiles([ |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, |
||||
|
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' } |
||||
|
]); |
||||
|
|
||||
|
const activities = await importActivities({ |
||||
|
activitiesDto: [ |
||||
|
createActivityDto({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: '2024-01-01T00:00:00.000Z', |
||||
|
symbol: 'AAPL', |
||||
|
type: 'FEE' |
||||
|
}), |
||||
|
createActivityDto({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: '2024-01-02T00:00:00.000Z', |
||||
|
symbol: 'AAPL', |
||||
|
type: 'BUY' |
||||
|
}) |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create |
||||
|
).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }); |
||||
|
|
||||
|
expect(getAssetProfileIdentifiers(activities)).toEqual([ |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, |
||||
|
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' } |
||||
|
]); |
||||
|
|
||||
|
expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([ |
||||
|
{ |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
date: parseISO('2024-01-01T00:00:00.000Z'), |
||||
|
symbol: CUSTOM_ASSET_PROFILE_SYMBOL |
||||
|
}, |
||||
|
{ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: parseISO('2024-01-02T00:00:00.000Z'), |
||||
|
symbol: 'AAPL' |
||||
|
} |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('shows the custom asset profile of a non-investment activity after an investment activity', async () => { |
||||
|
mockCreatedAssetProfiles([ |
||||
|
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }, |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } |
||||
|
]); |
||||
|
|
||||
|
const activities = await importActivities({ |
||||
|
activitiesDto: [ |
||||
|
createActivityDto({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: '2024-01-01T00:00:00.000Z', |
||||
|
symbol: 'AAPL', |
||||
|
type: 'BUY' |
||||
|
}), |
||||
|
createActivityDto({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: '2024-01-02T00:00:00.000Z', |
||||
|
symbol: 'AAPL', |
||||
|
type: 'FEE' |
||||
|
}) |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create |
||||
|
).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }); |
||||
|
|
||||
|
expect(getAssetProfileIdentifiers(activities)).toEqual([ |
||||
|
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }, |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it.each(NON_INVESTMENT_ACTIVITY_TYPES)( |
||||
|
'refers the next %s activities of a data provider to the custom asset profile which createActivity() has created', |
||||
|
async (type) => { |
||||
|
mockCreatedAssetProfiles([ |
||||
|
{ |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
symbol: CUSTOM_ASSET_PROFILE_SYMBOL |
||||
|
}, |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } |
||||
|
]); |
||||
|
|
||||
|
await importActivities({ |
||||
|
activitiesDto: [ |
||||
|
createActivityDto({ |
||||
|
type, |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: '2024-01-01T00:00:00.000Z', |
||||
|
symbol: 'AAPL' |
||||
|
}), |
||||
|
createActivityDto({ |
||||
|
type, |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: '2024-01-02T00:00:00.000Z', |
||||
|
symbol: 'AAPL' |
||||
|
}) |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create |
||||
|
).toMatchObject({ symbol: CUSTOM_ASSET_PROFILE_SYMBOL }); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('refers the next activities without a data source to the custom asset profile which createActivity() has created', async () => { |
||||
|
mockCreatedAssetProfiles([ |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } |
||||
|
]); |
||||
|
|
||||
|
await importActivities({ |
||||
|
activitiesDto: [ |
||||
|
createActivityDto({ |
||||
|
date: '2024-01-01T00:00:00.000Z', |
||||
|
symbol: 'Broker fee', |
||||
|
type: 'FEE' |
||||
|
}), |
||||
|
createActivityDto({ |
||||
|
date: '2024-01-02T00:00:00.000Z', |
||||
|
symbol: 'Broker fee', |
||||
|
type: 'FEE' |
||||
|
}) |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create |
||||
|
).toMatchObject({ |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
symbol: CUSTOM_ASSET_PROFILE_SYMBOL |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('refers the next investment activities without a data source to the custom asset profile which createActivity() has created', async () => { |
||||
|
mockCreatedAssetProfiles([ |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } |
||||
|
]); |
||||
|
|
||||
|
await importActivities({ |
||||
|
activitiesDto: [ |
||||
|
createActivityDto({ |
||||
|
date: '2024-01-01T00:00:00.000Z', |
||||
|
symbol: 'Gold', |
||||
|
type: 'BUY' |
||||
|
}), |
||||
|
createActivityDto({ |
||||
|
date: '2024-01-02T00:00:00.000Z', |
||||
|
symbol: 'Gold', |
||||
|
type: 'BUY' |
||||
|
}), |
||||
|
createActivityDto({ |
||||
|
date: '2024-01-03T00:00:00.000Z', |
||||
|
symbol: 'Gold', |
||||
|
type: 'SELL' |
||||
|
}) |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
createActivity.mock.calls.slice(1).map(([activity]) => { |
||||
|
return activity.SymbolProfile.connectOrCreate.create; |
||||
|
}) |
||||
|
).toMatchObject([ |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, |
||||
|
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } |
||||
|
]); |
||||
|
|
||||
|
expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([ |
||||
|
{ |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
date: parseISO('2024-01-01T00:00:00.000Z'), |
||||
|
symbol: CUSTOM_ASSET_PROFILE_SYMBOL |
||||
|
} |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('keeps the asset profiles of the activities in a dry run', async () => { |
||||
|
const activities = await importActivities({ |
||||
|
activitiesDto: [ |
||||
|
createActivityDto({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: '2024-01-01T00:00:00.000Z', |
||||
|
symbol: 'AAPL', |
||||
|
type: 'FEE' |
||||
|
}), |
||||
|
createActivityDto({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: '2024-01-02T00:00:00.000Z', |
||||
|
symbol: 'AAPL', |
||||
|
type: 'BUY' |
||||
|
}) |
||||
|
], |
||||
|
isDryRun: true |
||||
|
}); |
||||
|
|
||||
|
expect(createActivity).not.toHaveBeenCalled(); |
||||
|
expect(gatherSymbols).not.toHaveBeenCalled(); |
||||
|
|
||||
|
expect(getAssetProfileIdentifiers(activities)).toEqual([ |
||||
|
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }, |
||||
|
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
function importActivities({ |
||||
|
activitiesDto, |
||||
|
isDryRun |
||||
|
}: { |
||||
|
activitiesDto: CreateOrderDto[]; |
||||
|
isDryRun?: boolean; |
||||
|
}) { |
||||
|
return importService.import({ |
||||
|
activitiesDto, |
||||
|
isDryRun, |
||||
|
accountsWithBalancesDto: [], |
||||
|
assetProfilesWithMarketDataDto: [], |
||||
|
platformsDto: [], |
||||
|
tagsDto: [], |
||||
|
user: { |
||||
|
id: 'user-id', |
||||
|
permissions: [], |
||||
|
settings: { settings: { baseCurrency: 'USD' } } |
||||
|
} as unknown as UserWithSettings |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function mockCreatedAssetProfiles(assetProfiles: AssetProfileIdentifier[]) { |
||||
|
for (const assetProfile of assetProfiles) { |
||||
|
createActivity.mockImplementationOnce( |
||||
|
({ |
||||
|
date, |
||||
|
type |
||||
|
}: Parameters<ActivitiesService['createActivity']>[0]) => { |
||||
|
return { date, type, SymbolProfile: assetProfile }; |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
function createActivityDto({ |
||||
|
dataSource, |
||||
|
date, |
||||
|
symbol, |
||||
|
type |
||||
|
}: Pick<CreateOrderDto, 'dataSource' | 'date' | 'symbol' | 'type'>) { |
||||
|
return { |
||||
|
dataSource, |
||||
|
date, |
||||
|
symbol, |
||||
|
type, |
||||
|
currency: 'USD', |
||||
|
fee: 0, |
||||
|
quantity: 1, |
||||
|
unitPrice: 100 |
||||
|
} as CreateOrderDto; |
||||
|
} |
||||
|
|
||||
|
function getAssetProfileIdentifiers(activities: Activity[]) { |
||||
|
return activities.map(({ assetProfile: { dataSource, symbol } }) => { |
||||
|
return { dataSource, symbol }; |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
export interface LivenessHealthResponse { |
||||
|
status: string; |
||||
|
} |
||||
Loading…
Reference in new issue