Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: user import action #2270

Merged
merged 7 commits into from
Apr 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion documentation/docs/developers/server-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ yarn workspace api test:e2e -t app_user
Requires [docker desktop](https://www.docker.com/products/docker-desktop/) installed locally

1. Configure .env variables as per `packages\server\README.md`
Ensure `API_BASE_PATH="/api/"` to allow running as part of full stack
Ensure `API_BASE_PATH="/api"` to allow running as part of full stack

2. Build api
```bash
Expand Down
3 changes: 1 addition & 2 deletions packages/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ WEBSOCKET_PORT=3000
API_PORT=3000
# If running within docker containers this should be set to reverse proxy, e.g. /api/
# If serving locally this should be left blank
# REMEMBER TO REVERT TO /api/ WHEN BUILDING DOCKER IMAGE
API_BASE_PATH="/api/"
API_BASE_PATH=""
DB_HOST=localhost
DB_PORT=5432

Expand Down
2 changes: 1 addition & 1 deletion packages/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ RUN yarn build
# Add secondary set of production-only node_modules to dist (not sure why yarn berry needs re-init but seems to be the case)
# NOTE - if updating to yarn 4 will require additional plugin import line
RUN yarn set version 3.3.1 && \
# yarn plugin import workspace-tools && \
yarn plugin import workspace-tools && \
yarn workspaces focus api --production

### STAGE 2: Serve Dashboard ###
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "api",
"version": "1.4.3",
"version": "1.4.4",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
Expand Down
3 changes: 2 additions & 1 deletion packages/api/spec-export.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"parameters": [
{
"name": "x-deployment-db-name",
"required": true,
"in": "header",
"description": "Name of db for deployment to populate",
"schema": {
Expand Down Expand Up @@ -520,7 +521,7 @@
"info": {
"title": "IDEMS Apps API",
"description": "App-Server Communication",
"version": "1.3.0",
"version": "1.4.4",
"contact": {}
},
"tags": [
Expand Down
10 changes: 0 additions & 10 deletions packages/api/src/endpoints/app_users/app_user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,6 @@ export class AppUsersController {
return this.appUsersService.model.findAll();
}

// @Get(":app_user_id")
// findOne(@Param("app_user_id") app_user_id: string): Promise<AppUser> {
// return this.appUsersService.findOne(app_user_id);
// }

// @Delete(":id")
// remove(@Param("id") id: string): Promise<void> {
// return this.appUsersService.remove(id);
// }

@Get(":app_user_id")
@ApiParam({ name: "app_user_id", type: String })
@ApiOperation({ summary: "Get user profile" })
Expand Down
5 changes: 5 additions & 0 deletions packages/api/src/environment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@ function loadEnv() {
const { NODE_ENV } = process.env;

let envFilePath = resolve(__dirname, "../../.env");
// Hack - update path if running from compiled dist folder
if (envFilePath.includes("dist")) {
envFilePath = resolve(envFilePath, "../../.env");
}
if (NODE_ENV === "test") {
envFilePath = resolve(__dirname, "../../test/.test.env");
}
// In production env vars are passed from docker container instead of local file
if (!existsSync(envFilePath)) {
console.log(envFilePath);
console.warn("Env file does not exist, using local env variables", Object.keys(process.env));
return;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ async function bootstrap() {
.setDescription("App-Server Communication")
.setVersion(version)
.addTag("api")
// .setBasePath("/api")
// Fix swagger redirection issue
// https://github.com/nestjs/swagger/issues/448
// https://stackoverflow.com/questions/63954037/nestjs-swagger-missing-base-path
.addServer(environment.API_BASE_PATH ? `/${environment.API_BASE_PATH}` : "")
// Ensure api base path populated as /api (not /api/ or similar)
.addServer(environment.API_BASE_PATH ? `/${environment.API_BASE_PATH.replace(/\//g, "")}` : "")
.build();
const document = SwaggerModule.createDocument(app, config, { ignoreGlobalPrefix: true });
// add export for docs (https://github.com/nestjs/swagger/issues/158)
Expand Down
1 change: 1 addition & 0 deletions packages/data-models/flowTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ export namespace FlowTypes {
"toggle_field",
"track_event",
"trigger_actions",
"user",
] as const;

export interface TemplateRowAction {
Expand Down
16 changes: 8 additions & 8 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ You should be able to access the dashboard at http://localhost (will redirect)
http://localhost/dbadmin/

Login details
```
```yml
System: PostgreSQL
Server: db
Username: (POSTGRES_USER in .env)
Expand All @@ -79,7 +79,7 @@ http://localhost/dashboard/
A new database will need to be created to allow access for metabase. This should be automatically configured in [docker/config/db/init.sh](./docker/config/db/init.sh), however if these steps fail they can be run manually (currently a bit temperamental - TODO ISSUE - will know if successful if can see a database created that matches the provided $MB_DB_DBNAME).

Manual SQL can be executed from the Adminer DB interface, e.g. using the example config:
```
```sql
CREATE USER metabase WITH PASSWORD 'metabase';
CREATE DATABASE metabase;
GRANT ALL PRIVILEGES ON DATABASE metabase to metabase;
Expand All @@ -88,13 +88,13 @@ GRANT ALL PRIVILEGES ON DATABASE metabase to metabase;
Once running complete configuration from within the dashboard app.

Create a user account using preferred credentials and retain securely elsewhere. As a default when running test servers in docker the following credentials are used
```
```yml
email: [email protected]
password: demo1234
```

Configure connection to the same database created by the api:
```
```yml
Database type: PostgreSQL
Name: (any)
Host: db
Expand All @@ -111,7 +111,7 @@ You should then see the main dashboard page
http://localhost/analytics/

An initial set of configuration screens should walk through the process of setting up users and a database connection. If connection fails or additional users need to be created the database can be accessed via the same Adminer `/dbadmin` path and `root` user credentials
```
```yml
System: MySQL
Server: analytics_db
Username: root
Expand All @@ -120,13 +120,13 @@ Database: (blank)
```
You will be asked to create a super user login and password. This information should be stored safely.
As a default when running test servers in docker the following credentials are used
```
```yml
email: [email protected]
password: demo1234
```

To enable data collection from the frontend application follow instructions in the dashboard. You may need to record the Matomo Url and site ID as seen on the initial page, e.g.
```
```yml
Matomo URL: http://localhost/
Your site ID: 1
```
Expand All @@ -150,7 +150,7 @@ http://localhost/triggers/
This uses the tool [N8N](https://n8n.io/) to provide access to automation and triggers based on events.
Currently the tool only supports single user authentication provided by username and password provided in the `.env` file

```
```yml
Username: ($N8N_BASIC_AUTH_USER in .env)
Password: ($N8N_BASIC_AUTH_PASSWORD in .env)
```
Expand Down
4 changes: 2 additions & 2 deletions packages/server/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ services:
# context: ../../api
# dockerfile: Dockerfile
# target: prod-env
image: idems/apps-api:1.4.3
image: idems/apps-api:1.4.4
env_file:
- ../../api/.env
environment:
Expand Down Expand Up @@ -89,7 +89,7 @@ services:
# Postgres DB admin (pgadmin)
####################################################################
pgadmin:
image: dpage/pgadmin4:6
image: dpage/pgadmin4:8
container_name: plh_pg_admin
restart: unless-stopped
env_file:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class SelectTextComponent

getParams() {
this.placeholder = getStringParamFromTemplateRow(this._row, "placeholder", "");
this.maxLength = getNumberParamFromTemplateRow(this._row, "max_length", 30);
this.maxLength = getNumberParamFromTemplateRow(this._row, "max_length", -1);
this.textAlign = getStringParamFromTemplateRow(this._row, "text_align", "center");
this.style = getStringParamFromTemplateRow(this._row, "style", null);
this.isNumberInput = getBooleanParamFromTemplateRow(this._row, "number_input", false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class TmplTextBoxComponent extends TemplateBaseComponent implements OnIni

getParams() {
this.placeholder = getStringParamFromTemplateRow(this._row, "placeholder", "");
this.maxLength = getNumberParamFromTemplateRow(this._row, "max_length", 30);
this.maxLength = getNumberParamFromTemplateRow(this._row, "max_length", -1);
this.textAlign = getStringParamFromTemplateRow(this._row, "text_align", "center");
this.style = getStringParamFromTemplateRow(this._row, "style", null);
this.isNumberInput = getBooleanParamFromTemplateRow(this._row, "number_input", false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,31 @@ import { booleanStringToBoolean } from "src/app/shared/utils";
import { TemplateTranslateService } from "./template-translate.service";
import { AsyncServiceBase } from "src/app/shared/services/asyncService.base";
import { TemplateActionRegistry } from "./instance/template-action.registry";
import { AppConfigService } from "src/app/shared/services/app-config/app-config.service";

@Injectable({ providedIn: "root" })
export class TemplateFieldService extends AsyncServiceBase {
globals: { [name: string]: FlowTypes.GlobalRow } = {};

/** App config prefix used */
public prefix: string;

constructor(
private localStorageService: LocalStorageService,
private dbService: DbService,
private translateService: TemplateTranslateService,
private templateActionRegistry: TemplateActionRegistry
private templateActionRegistry: TemplateActionRegistry,
private appConfigService: AppConfigService
) {
super("TemplateField");
this.registerInitFunction(this.initialise);
this.registerTemplateActionHandlers();
this.prefix = appConfigService.APP_CONFIG.FIELD_PREFIX;
}

private async initialise() {
await this.ensureAsyncServicesReady([this.dbService, this.translateService]);
this.ensureSyncServicesReady([this.localStorageService]);
this.ensureSyncServicesReady([this.localStorageService, this.appConfigService]);
}

private registerTemplateActionHandlers() {
Expand All @@ -49,7 +55,7 @@ export class TemplateFieldService extends AsyncServiceBase {
* TODO - ideally showWarnings should be linked to some sort of debug mode
*/
public getField(key: string, showWarnings = true) {
let val: any = this.localStorageService.getString("rp-contact-field." + key);
let val: any = this.localStorageService.getString(`${this.prefix}.${key}`);
// provide a fallback if the target variable does not exist in local storage
if (val === null && showWarnings) {
// console.warn("field value not found for key:", key);
Expand Down Expand Up @@ -80,7 +86,7 @@ export class TemplateFieldService extends AsyncServiceBase {
}
}
// write to local storage - this will cast to string
this.localStorageService.setString("rp-contact-field." + key, value);
this.localStorageService.setString(`${this.prefix}.${key}`, value);

// write to db - note this can handle more data formats but only string/number will be available to queries
if (typeof value === "boolean") value = "value";
Expand Down
2 changes: 2 additions & 0 deletions src/app/shared/services/server/interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ let { db_name, endpoint: API_ENDPOINT } = environment.deploymentConfig.api;

// Override development credentials when running locally
if (!environment.production) {
// Docker endpoint. Replace :3000 with /api if running standalone api
API_ENDPOINT = "http://localhost:3000";
db_name = "dev";
}

/** Handle updating urls intended for api server */
Expand Down
62 changes: 59 additions & 3 deletions src/app/shared/services/userMeta/userMeta.service.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Device } from "@capacitor/device";
import { firstValueFrom } from "rxjs";

import { AsyncServiceBase } from "../asyncService.base";
import { DbService } from "../db/db.service";
import { TemplateActionRegistry } from "../../components/template/services/instance/template-action.registry";
import { TemplateFieldService } from "../../components/template/services/template-field.service";

@Injectable({ providedIn: "root" })
export class UserMetaService extends AsyncServiceBase {
/** keep an in-memory copy of user to provide synchronously */
public userMeta: IUserMeta;
constructor(private dbService: DbService) {
super("UsesrMetaService");
constructor(
private dbService: DbService,
private templateActionRegistry: TemplateActionRegistry,
private http: HttpClient,
private fieldService: TemplateFieldService
) {
super("UserMetaService");
this.registerInitFunction(this.initialise);
}

/** When first initialising ensure a default profile created and any newer defaults are merged with older user profiles */
private async initialise() {
await this.ensureAsyncServicesReady([this.dbService]);
await this.ensureAsyncServicesReady([this.dbService, this.fieldService]);
this.registerUserActions();
const userMetaValues = await this.dbService.table<IUserMetaEntry>("user_meta").toArray();
const userMeta: IUserMeta = USER_DEFAULTS;
userMetaValues.forEach((v) => {
Expand All @@ -27,6 +38,8 @@ export class UserMetaService extends AsyncServiceBase {
}
userMeta.uuid = uuid;
this.userMeta = userMeta;
// populate user id contact field
this.fieldService.setField("_app_user_id", uuid);
}

getUserMeta(key: keyof IUserMeta) {
Expand All @@ -38,6 +51,49 @@ export class UserMetaService extends AsyncServiceBase {
await this.dbService.table<IUserMetaEntry>("user_meta").bulkPut(entries as any);
this.userMeta = { ...this.userMeta, ...meta };
}

/** Import existing user contact fields and replace current user */
private async importUserFields(id: string) {
try {
// TODO - get type-safe return types using openapi http client
const profile = await firstValueFrom(
this.http.get(`/app_users/${id}`, { responseType: "json" })
);
if (!profile) {
console.error("[User Import] not found:" + id);
return;
}
const { contact_fields } = profile as any;
for (const [key, value] of Object.entries(contact_fields)) {
const fieldName = key.replace(`${this.fieldService.prefix}.`, "");
// TODO - handle special contact fields as required (e.g. _app_skin, _app_theme)
if (!fieldName.startsWith("_")) {
await this.fieldService.setField(fieldName, value as string);
}
}
} catch (error) {
console.error("[User Import] failed", error);
}
}

private registerUserActions() {
const childActions = {
import: this.importUserFields.bind(this),
};
const childActionNames = Object.keys(childActions).join(",");
this.templateActionRegistry.register({
user: async ({ args }) => {
const [actionId, ...childArgs] = args;
if (!childActions[actionId]) {
console.error(
`[${actionId}] user action not defined. Available actions:\n${childActionNames}`
);
return;
}
return childActions[actionId](childArgs);
},
});
}
}

interface IUserMetaEntry {
Expand Down
Loading