diff --git a/documentation/docs/developers/server-development.md b/documentation/docs/developers/server-development.md index 2222ca0645..f8b6944d62 100644 --- a/documentation/docs/developers/server-development.md +++ b/documentation/docs/developers/server-development.md @@ -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 diff --git a/package.json b/package.json index 515153c352..d6cda943fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.16.27", + "version": "0.16.28", "author": "IDEMS International", "license": "See LICENSE", "homepage": "https://idems.international/", diff --git a/packages/api/.env.example b/packages/api/.env.example index aefb664b9a..2663ca61c0 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -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 diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 31a3c6f6e5..1986586a0b 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -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 ### diff --git a/packages/api/package.json b/packages/api/package.json index b20f4d4d07..2843667f94 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "1.4.3", + "version": "1.4.4", "scripts": { "prebuild": "rimraf dist", "build": "nest build", diff --git a/packages/api/spec-export.json b/packages/api/spec-export.json index fc4c4b9792..6944d4a9d7 100644 --- a/packages/api/spec-export.json +++ b/packages/api/spec-export.json @@ -8,6 +8,7 @@ "parameters": [ { "name": "x-deployment-db-name", + "required": true, "in": "header", "description": "Name of db for deployment to populate", "schema": { @@ -520,7 +521,7 @@ "info": { "title": "IDEMS Apps API", "description": "App-Server Communication", - "version": "1.3.0", + "version": "1.4.4", "contact": {} }, "tags": [ diff --git a/packages/api/src/endpoints/app_users/app_user.controller.ts b/packages/api/src/endpoints/app_users/app_user.controller.ts index 98ce562566..b4b15d1342 100644 --- a/packages/api/src/endpoints/app_users/app_user.controller.ts +++ b/packages/api/src/endpoints/app_users/app_user.controller.ts @@ -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 { - // return this.appUsersService.findOne(app_user_id); - // } - - // @Delete(":id") - // remove(@Param("id") id: string): Promise { - // return this.appUsersService.remove(id); - // } - @Get(":app_user_id") @ApiParam({ name: "app_user_id", type: String }) @ApiOperation({ summary: "Get user profile" }) diff --git a/packages/api/src/environment/index.ts b/packages/api/src/environment/index.ts index cfc8ccf518..e3a29e2865 100644 --- a/packages/api/src/environment/index.ts +++ b/packages/api/src/environment/index.ts @@ -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; } diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts index e19abe3809..aa55c489e1 100644 --- a/packages/api/src/main.ts +++ b/packages/api/src/main.ts @@ -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) diff --git a/packages/data-models/flowTypes.ts b/packages/data-models/flowTypes.ts index e4afd9c7a2..215cf2906e 100644 --- a/packages/data-models/flowTypes.ts +++ b/packages/data-models/flowTypes.ts @@ -416,6 +416,7 @@ export namespace FlowTypes { "toggle_field", "track_event", "trigger_actions", + "user", ] as const; export interface TemplateRowAction { diff --git a/packages/server/README.md b/packages/server/README.md index e32291f9e8..211fb8c417 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -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) @@ -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; @@ -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: demo@demo.com password: demo1234 ``` Configure connection to the same database created by the api: -``` +```yml Database type: PostgreSQL Name: (any) Host: db @@ -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 @@ -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: demo@demo.com 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 ``` @@ -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) ``` diff --git a/packages/server/docker/docker-compose.yml b/packages/server/docker/docker-compose.yml index 06f518c733..6278adf31b 100644 --- a/packages/server/docker/docker-compose.yml +++ b/packages/server/docker/docker-compose.yml @@ -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: @@ -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: diff --git a/src/app/shared/components/template/components/select-text/select-text.component.ts b/src/app/shared/components/template/components/select-text/select-text.component.ts index 954b9f6bc8..489f682cd5 100644 --- a/src/app/shared/components/template/components/select-text/select-text.component.ts +++ b/src/app/shared/components/template/components/select-text/select-text.component.ts @@ -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); diff --git a/src/app/shared/components/template/components/text-box/text-box.component.ts b/src/app/shared/components/template/components/text-box/text-box.component.ts index ced9a3be45..2d53d92b14 100644 --- a/src/app/shared/components/template/components/text-box/text-box.component.ts +++ b/src/app/shared/components/template/components/text-box/text-box.component.ts @@ -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); diff --git a/src/app/shared/components/template/services/template-field.service.ts b/src/app/shared/components/template/services/template-field.service.ts index 9750e2c76b..47c04f125b 100644 --- a/src/app/shared/components/template/services/template-field.service.ts +++ b/src/app/shared/components/template/services/template-field.service.ts @@ -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() { @@ -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); @@ -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"; diff --git a/src/app/shared/services/server/interceptors.ts b/src/app/shared/services/server/interceptors.ts index cf5ec62a1a..85e5119a7d 100644 --- a/src/app/shared/services/server/interceptors.ts +++ b/src/app/shared/services/server/interceptors.ts @@ -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 */ diff --git a/src/app/shared/services/userMeta/userMeta.service.ts b/src/app/shared/services/userMeta/userMeta.service.ts index 0a5e6c1628..fc8d10facc 100644 --- a/src/app/shared/services/userMeta/userMeta.service.ts +++ b/src/app/shared/services/userMeta/userMeta.service.ts @@ -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("user_meta").toArray(); const userMeta: IUserMeta = USER_DEFAULTS; userMetaValues.forEach((v) => { @@ -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) { @@ -38,6 +51,49 @@ export class UserMetaService extends AsyncServiceBase { await this.dbService.table("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 { diff --git a/src/theme/themes/_index.scss b/src/theme/themes/_index.scss index 65a521be7f..910ca9b35a 100644 --- a/src/theme/themes/_index.scss +++ b/src/theme/themes/_index.scss @@ -1,3 +1,4 @@ @forward "./default.scss"; -@forward "./pfr.scss"; @forward "./professional.scss"; +@forward "./pfr.scss"; +@forward "./plh_facilitator_mx.scss"; diff --git a/src/theme/themes/plh_facilitator_mx.scss b/src/theme/themes/plh_facilitator_mx.scss new file mode 100644 index 0000000000..00a5e619b7 --- /dev/null +++ b/src/theme/themes/plh_facilitator_mx.scss @@ -0,0 +1,57 @@ +@use "./utils"; +@use "sass:color"; + +@mixin theme-plh_facilitator_mx { + [data-theme="plh_facilitator_mx"] { + /** Authoring variables **/ + $color-primary: #0e3a5a; + $color-secondary: #ff5e00; + $page-background: white; + + /** Authoring component overrides **/ + $variable-overrides: ( + button-background-primary: var(--ion-color-primary-500), + button-background-secondary: var(--ion-color-secondary), + button-background-option: var(--ion-color-primary-800), + round-button-background-secondary-light: var(--ion-color-yellow), + // round-button-background-secondary-mid: #fa9529, + // round-button-background-secondary-dark: #F87023, + // tile-button-background-default: #a3d9fa, + tile-button-background-primary: var(--ion-color-primary-500), + tile-button-background-primary-light: var(--ion-color-primary-300), + tile-button-background-secondary: var(--ion-color-secondary), + tile-button-background-secondary-light: var(--ion-color-yellow), + // audio-control-background: #1980d2, + points-item-background: var(--ion-background-color), + points-item-background-complete: var(--ion-color-primary-200), + points-item-border: rgba(black, 0.07), + display-group-background-banner-primary: var(--ion-color-primary-200), + display-group-background-banner-secondary: var(--ion-color-secondary-300), + // display-group-background-tool-1: #fa8e29, + // display-group-background-tool-2: #ff7b00, + // display-group-background-tool-3: #108ab2, + // display-group-background-tool-4: #096a8b, + // display-group-background-tool-5: $color-primary, + display-group-background-home-light: var(--ion-color-primary-300), + display-group-background-home-mid: var(--ion-color-primary-600), + display-group-background-home-dark: var(--ion-color-primary-800), + // timer-button-background: #1985d2, + // combo-box-placeholder-text: rgba(13, 64, 96, 0.5), + // combo-box-background-no-value: transparent, + combo-box-background-with-value: var(--ion-color-primary-300), + // slider-ui-color: #096e90, + accordion-background-highlight: var(--ion-color-primary-300), + tour-next-button-background: var(--ion-color-secondary), + radio-group-background-selected: var(--ion-color-primary-300), + // radio-button-font-size: 1.25rem, + // radio-button-font-color: var(--ion-color-primary), + ion-item-background: var(--ion-color-gray-light), + task-progress-bar-color: var(--ion-color-green), + // checkbox-background-color: white + ); + @include utils.generateTheme($color-primary, $color-secondary, $page-background); + @each $name, $value in $variable-overrides { + --#{$name}: #{$value}; + } + } +} diff --git a/src/theme/variables.scss b/src/theme/variables.scss index ba5ce017a4..02525058a1 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -12,6 +12,7 @@ @include themes.theme-default; @include themes.theme-professional; @include themes.theme-pfr; +@include themes.theme-plh_facilitator_mx; /** Ionic CSS Variables **/ :root {