Skip to content

Commit 7c48da3

Browse files
authored
LKE-14042 Load more details from Company House UK (#24)
* LKE-14042 add support for loading officers and persons with significant control in the Company House UK integration * LKE-14042 make saveConfig fast by avoiding rebooting all plugins to save the config * LKE-14042 improve layout after adding new nodes (pin existing nodes but take them into account in the layout) * LKE-14042 fix tests * LKE-14042 fix linter * LKE-14042 open the plugin in a modal if possible --------- Signed-off-by: davidrapin <david@linkurio.us>
1 parent 58794d7 commit 7c48da3

28 files changed

Lines changed: 843 additions & 652 deletions

package-lock.json

Lines changed: 110 additions & 511 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"url": "https://github.com/Linkurious/lke-plugin-third-party-data"
1212
},
1313
"engines": {
14-
"node": "20.18.0"
14+
"node": "22.19.0"
1515
},
1616
"author": {
1717
"name": "David Rapin",
@@ -28,6 +28,7 @@
2828
"lint": "npm run lint -ws",
2929
"lint:fix": "npm run lint:fix -ws",
3030
"lint:ci": "eslint -f checkstyle -o reports/checkstyle.xml .",
31+
"tsc": "npm run build -ws",
3132
"build": "npm run build -ws",
3233
"test": "npm test -w=packages/backend",
3334
"postbuild": "mkdir -p tmp/github_release && npm pack && mv $(npm run --silent npm-package-name) $(npm run --silent artifact-name) && ln -sf $(pwd)/$(npm run --silent artifact-name) $(pwd)/tmp/github_release/lke-plugin-$(npm run --silent plugin-name)-v$(cat .version).lke",
@@ -44,19 +45,16 @@
4445
"LICENSE"
4546
],
4647
"bundledDependencies": true,
47-
"dependencies": {
48-
"@linkurious/rest-client": "4.1.6",
49-
"express": "4.21.2",
50-
"superagent": "10.1.1",
51-
"https-proxy-agent": "7.0.6"
52-
},
48+
"dependencies": {},
5349
"devDependencies": {
50+
"superagent": "10.2.2",
5451
"@typescript-eslint/eslint-plugin": "6.16.0",
5552
"eslint": "8.56.0",
5653
"eslint-config-prettier": "9.1.0",
5754
"eslint-plugin-import": "2.29.1",
5855
"eslint-plugin-prettier": "5.1.2",
59-
"typescript": "5.3.3"
56+
"typescript": "5.3.3",
57+
"https-proxy-agent": "7.0.6"
6058
},
6159
"workspaces": [
6260
"packages/backend",

packages/backend/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
"https-proxy-agent"
1616
],
1717
"dependencies": {
18+
"@linkurious/rest-client": "4.2.5",
1819
"express": "4.21.2",
19-
"superagent": "10.1.1",
20+
"superagent": "10.2.2",
2021
"https-proxy-agent": "7.0.6"
2122
},
2223
"devDependencies": {
2324
"@types/express": "4.17.21",
24-
"@types/node": "18.19.3",
25-
"@types/superagent": "4.1.15"
25+
"@types/node": "22.18.10",
26+
"@types/superagent": "8.1.9",
27+
"@types/http-proxy-agent": "4.0.1"
2628
}
2729
}

packages/backend/src/dev/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ plugin({
2323
}
2424
return new RestClient({
2525
baseUrl: '/',
26-
agent: agent
26+
agent: agent as unknown as superagent.SuperAgentStatic
2727
});
2828
},
2929
configuration: {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {clone} from '../../../shared/utils';
2+
3+
export class ConfigOptions {
4+
public readonly data: Record<string, unknown>;
5+
6+
private constructor(config: Record<string, unknown>) {
7+
this.data = config;
8+
}
9+
10+
static from(requestBody: unknown): ConfigOptions {
11+
if (typeof requestBody !== 'object' || requestBody === null) {
12+
throw new Error('Configuration content must be an object');
13+
}
14+
return new ConfigOptions(clone(requestBody) as Record<string, unknown>);
15+
}
16+
}

packages/backend/src/routes.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,34 @@ import express from 'express';
33

44
import {MyPluginConfig} from '../../shared/myPluginConfig';
55
import {ApiResponse} from '../../shared/api/response';
6+
import {asError} from '../../shared/utils';
67

78
import {ServiceFacade} from './services/serviceFacade';
89
import {SearchOptions} from './models/searchOptions';
910
import {DetailsOptions} from './models/detailsOptions';
11+
import {ConfigOptions} from './models/configOptions';
1012

1113
export = function (pluginInterface: PluginRouteOptions<MyPluginConfig>): void {
1214
const services = new ServiceFacade(pluginInterface);
15+
pluginInterface.router.use(express.json());
1316

17+
// read the admin config
1418
pluginInterface.router.get(
1519
'/admin-config',
16-
respond(async () => {
17-
return services.getConfigAdmin();
20+
respond(async (req) => {
21+
return services.getConfigAdmin(req);
1822
})
1923
);
2024

25+
// update the admin config
26+
pluginInterface.router.post(
27+
'/admin-config',
28+
respond(async (req: express.Request) => {
29+
const config = ConfigOptions.from(req.body);
30+
return services.setConfigAdmin(req, config);
31+
}, 201)
32+
);
33+
2134
pluginInterface.router.get(
2235
'/config',
2336
respond(async () => {
@@ -43,13 +56,13 @@ export = function (pluginInterface: PluginRouteOptions<MyPluginConfig>): void {
4356
};
4457

4558
function respond(
46-
handler: (req: express.Request) => Promise<ApiResponse>,
59+
handler: (req: express.Request) => Promise<ApiResponse | undefined>,
4760
successStatus = 200
4861
): express.RequestHandler {
4962
return (req, res) => {
5063
Promise.resolve(handler(req))
5164
.then((response) => {
52-
if (response.error) {
65+
if (response && response.error) {
5366
res.status(500);
5467
} else {
5568
res.status(successStatus);
@@ -59,9 +72,10 @@ function respond(
5972
.catch((e) => {
6073
const response: ApiResponse = {};
6174
if (e instanceof Error) {
75+
console.error(e.stack);
6276
response.error = {code: e.name, message: e.message};
6377
} else {
64-
response.error = {code: 'unexpected', message: JSON.stringify(e)};
78+
response.error = {code: 'unexpected', message: asError(e).message};
6579
}
6680
res.status(500).json(response);
6781
});

packages/backend/src/services/configuration.ts

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,131 @@
1+
import express = require('express');
2+
13
import {MyPluginConfig, MyPluginConfigPublic} from '../../../shared/myPluginConfig';
24
import {clone} from '../../../shared/utils';
35
import {IntegrationModelPublic} from '../../../shared/integration/IntegrationModel';
46
import {VendorIntegration} from '../../../shared/integration/vendorIntegration';
57
import {STRINGS} from '../../../shared/strings';
8+
import {API} from '../server/api';
9+
import {ConfigOptions} from '../models/configOptions';
10+
import {Vendors} from '../../../shared/vendor/vendors';
611

712
import {Logger} from './logger';
813

14+
type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
15+
16+
type ValidConfig = RequiredFields<MyPluginConfig, 'integrations'>;
17+
918
export class Configuration {
10-
public readonly config: Required<MyPluginConfig>;
19+
private static MY_CONFIG_KEY = 'third-party-data';
20+
private config: ValidConfig;
1121
// @ts-ignore
1222
private readonly logger: Logger;
23+
private readonly api: API;
1324

14-
constructor(config: MyPluginConfig, logger: Logger) {
25+
constructor(config: MyPluginConfig, api: API, logger: Logger) {
1526
this.logger = logger;
27+
this.api = api;
1628
this.config = Configuration.validate(config);
1729
}
1830

1931
getPublicConfig(): MyPluginConfigPublic {
2032
const configClone = clone(this.config);
2133
return {
2234
basePath: configClone.basePath,
23-
integrations: configClone.integrations.map((integration) => ({
35+
integrations: (configClone.integrations ?? []).map((integration) => ({
2436
...integration,
2537
adminSettings: undefined
2638
})) as IntegrationModelPublic[]
2739
};
2840
}
2941

42+
getConfigFull(): MyPluginConfig {
43+
return this.config;
44+
}
45+
46+
async setConfigFull(req: express.Request, config: ConfigOptions): Promise<void> {
47+
// validate
48+
const validConfig = Configuration.validate(config.data);
49+
50+
// update the LKE config
51+
const pathToUpdate = await this.getLkeConfigPath(req);
52+
this.logger.info(`Updating plugin config in: ${pathToUpdate}`);
53+
await this.api.server(req).config.updateConfiguration({
54+
path: pathToUpdate,
55+
configuration: validConfig
56+
});
57+
58+
// update the plugin's config cache
59+
this.config = validConfig;
60+
}
61+
62+
private async getLkeConfigPath(req: express.Request): Promise<string> {
63+
const fullLkeConfigR = await this.api.server(req).config.getConfiguration();
64+
if (!fullLkeConfigR.isSuccess()) {
65+
throw new Error('Cannot load Linkurious configuration');
66+
}
67+
const configs = (fullLkeConfigR.body.plugins ?? {})[Configuration.MY_CONFIG_KEY];
68+
if (Array.isArray(configs)) {
69+
if (configs.length === 1) {
70+
return `plugins.${Configuration.MY_CONFIG_KEY}.0`;
71+
}
72+
const basePath = this.config.basePath;
73+
const index = configs.findIndex((config: ValidConfig) => config.basePath === basePath);
74+
if (index < 0) {
75+
throw new Error(`Cannot find the configuration path for ${Configuration.MY_CONFIG_KEY}`);
76+
}
77+
return `plugins.${Configuration.MY_CONFIG_KEY}.${index}`;
78+
} else {
79+
return `plugins.${Configuration.MY_CONFIG_KEY}`;
80+
}
81+
}
82+
3083
/**
3184
* Validate the plugin configuration parameters and add eventual default values.
3285
* Terminate the plugin in case of errors.
3386
*/
34-
static validate(config: Partial<MyPluginConfig>): Required<MyPluginConfig> {
87+
static validate(config: Partial<MyPluginConfig>): ValidConfig {
88+
if (typeof config.basePath !== 'string') {
89+
throw new Error('Invalid configuration: basePath must be a string');
90+
}
3591
if (config.integrations === undefined) {
3692
config.integrations = [];
3793
}
94+
for (const integration of config.integrations) {
95+
const vendor = Vendors.getVendorByKey(integration.vendorKey);
96+
const as = integration.adminSettings;
97+
if (typeof as !== 'object' || as === null) {
98+
throw new Error(`${vendor.key}: "adminSettings" is invalid (must be an object)`);
99+
}
100+
for (const [key, value] of Object.entries(as)) {
101+
const field = vendor.adminFields.find((adminField) => adminField.key === key);
102+
if (!field) {
103+
throw new Error(`${vendor.key}: "adminSettings" field ${key} is unexpected`);
104+
}
105+
if (value === undefined) {
106+
if (field.required) {
107+
throw new Error(`${vendor.key}: adminSettings.${key} is required`);
108+
} else {
109+
// not required and undefined, okay
110+
}
111+
} else {
112+
// value is defined
113+
if (field.type === 'string' && typeof value !== 'string') {
114+
throw new Error(`${vendor.key}: adminSettings.${key} must be a string`);
115+
}
116+
if (field.type === 'boolean' && typeof value !== 'boolean') {
117+
throw new Error(`${vendor.key}: adminSettings.${key} must be a boolean`);
118+
}
119+
}
120+
}
121+
}
38122
/*
39123
if (config.mandatoryParam === null || config.mandatoryParam === undefined) {
40124
logger.error('Missing mandatory parameter `mandatoryParam`.');
41125
// process.exit(1);
42126
}
43127
*/
44-
return config as Required<MyPluginConfig>;
128+
return config as ValidConfig;
45129
}
46130

47131
getIntegrationById(integrationId: string): VendorIntegration {

packages/backend/src/services/logger.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export class Logger {
99
console.log(this.date + ' - ' + message);
1010
}
1111

12+
warn(message: string): void {
13+
console.warn(this.date + ' - ' + message);
14+
}
15+
1216
error(message: string, error?: Error): void {
1317
console.error(this.date + ' - ' + message + (error ? ': ' + error.stack : ''));
1418
}

packages/backend/src/services/serviceFacade.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import {SearchOptions} from '../models/searchOptions';
1414
import {asError} from '../../../shared/utils';
1515
import {DetailsOptions} from '../models/detailsOptions';
16+
import {ConfigOptions} from '../models/configOptions';
1617

1718
import {Configuration} from './configuration';
1819
import {Logger} from './logger';
@@ -25,8 +26,8 @@ export class ServiceFacade {
2526

2627
constructor(options: PluginRouteOptions<MyPluginConfig>) {
2728
this.logger = new Logger();
28-
this.config = new Configuration(options.configuration, this.logger);
2929
this.api = new API(options, this.logger);
30+
this.config = new Configuration(options.configuration, this.api, this.logger);
3031
this.logger.info('ServiceFacade initialized');
3132
}
3233

@@ -38,8 +39,21 @@ export class ServiceFacade {
3839
return false;
3940
}
4041

41-
async getConfigAdmin(): Promise<MyPluginConfig & ApiResponse> {
42-
return this.config.config;
42+
private async ensureAdmin(req: express.Request): Promise<void> {
43+
const isAdmin = await this.currentUserIsAdmin(req);
44+
if (!isAdmin) {
45+
throw new Error('Not authorized');
46+
}
47+
}
48+
49+
async getConfigAdmin(req: express.Request): Promise<MyPluginConfig & ApiResponse> {
50+
await this.ensureAdmin(req);
51+
return this.config.getConfigFull();
52+
}
53+
54+
async setConfigAdmin(req: express.Request, config: ConfigOptions): Promise<undefined> {
55+
await this.ensureAdmin(req);
56+
await this.config.setConfigFull(req, config);
4357
}
4458

4559
async getConfigUser(): Promise<MyPluginConfigPublic & ApiResponse> {

packages/backend/src/services/vendor/baseSearchDriver.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,11 @@ export abstract class BaseDetailsSearchDriver<
9393
): Promise<VendorResult<DR>>;
9494
}
9595

96-
export function flattenJson(json: Record<string, unknown>): Record<string, VendorFieldType> {
96+
export function flattenJson(json: unknown): Record<string, VendorFieldType> {
9797
const result: Record<string, VendorFieldType> = {};
98+
if (typeof json !== 'object' || json === null) {
99+
return result;
100+
}
98101
for (const [key, value] of Object.entries(json)) {
99102
if (value !== null && typeof value === 'object') {
100103
flattenJsonField(result, key, value as Record<string, unknown>);

0 commit comments

Comments
 (0)