diff --git a/projects/ngx-ramblers/src/app/date-picker/date-picker.component.ts b/projects/ngx-ramblers/src/app/date-picker/date-picker.component.ts index c822b07..2933310 100644 --- a/projects/ngx-ramblers/src/app/date-picker/date-picker.component.ts +++ b/projects/ngx-ramblers/src/app/date-picker/date-picker.component.ts @@ -36,7 +36,7 @@ export class DatePickerComponent implements OnInit, OnChanges { constructor( private dateUtils: DateUtilsService, loggerFactory: LoggerFactory) { - this.logger = loggerFactory.createLogger("DatePickerComponent", NgxLoggerLevel.OFF); + this.logger = loggerFactory.createLogger("DatePickerComponent", NgxLoggerLevel.ERROR); } ngOnChanges(changes: SimpleChanges) { diff --git a/projects/ngx-ramblers/src/app/models/member.model.ts b/projects/ngx-ramblers/src/app/models/member.model.ts index 089efa4..4b445cc 100644 --- a/projects/ngx-ramblers/src/app/models/member.model.ts +++ b/projects/ngx-ramblers/src/app/models/member.model.ts @@ -2,6 +2,8 @@ import { ApiResponse, Identifiable } from "./api-response.model"; import { MailchimpSubscription } from "./mailchimp.model"; import { MailIdentifiers, MailSubscription } from "./mail.model"; import { sortBy } from "../functions/arrays"; +import { Walk } from "./walk.model"; +import { Contact } from "./ramblers-walks-manager"; export enum ProfileUpdateType { LOGIN_DETAILS = "login details", @@ -136,9 +138,26 @@ export interface HasEmailFirstAndLastName { export interface BulkLoadMemberAndMatch { memberAction: MemberAction; memberMatchType: string; + contact: Contact; + ramblersMember: RamblersMember; member: Member; } +export interface WalksImportPreparation { + bulkLoadMembersAndMatchesToWalks: BulkLoadMemberAndMatchToWalks[]; + existingWalksWithinRange: Walk[]; +} + +export interface BulkLoadMemberAndMatchToWalks { + bulkLoadMemberAndMatch: BulkLoadMemberAndMatch; + walks: Walk[]; +} + +export interface RamblersMemberAndContact { + contact: Contact; + ramblersMember: RamblersMember; +} + export interface RamblersMember extends HasEmailFirstAndLastName { groupMember?: boolean; membershipExpiryDate?: string | number; @@ -176,6 +195,8 @@ export interface MemberUpdateAudit extends Auditable { } export enum MemberAction { + found = "found", + notFound = "not-found", created = "created", complete = "complete", summary = "summary", diff --git a/projects/ngx-ramblers/src/app/models/ramblers-walks-manager.ts b/projects/ngx-ramblers/src/app/models/ramblers-walks-manager.ts index 97abd34..420c3b7 100644 --- a/projects/ngx-ramblers/src/app/models/ramblers-walks-manager.ts +++ b/projects/ngx-ramblers/src/app/models/ramblers-walks-manager.ts @@ -74,13 +74,28 @@ export interface MetadataDescription { export interface Metadata extends MetadataCode, MetadataDescription { } -export interface WalkLeader { +export interface Contact { id: string; name: string; telephone: string; - has_email: true; + has_email: boolean; email_form?: string; - is_overridden: false; + is_overridden: boolean; +} + +export interface LocationDetails { + latitude: number; + longitude: number; + grid_reference_6: string; + grid_reference_8: string; + postcode: string; + description: string; + w3w: string; +} + +export interface Difficulty { + code: string; + description: string; } export interface GroupWalk { @@ -95,13 +110,7 @@ export interface GroupWalk { start_date_time: string; end_date_time: string; meeting_date_time: string; - event_organiser?: { - name: string; - telephone: string; - has_email: boolean; - is_overridden: boolean; - email_form: string; - }, + event_organiser?: Contact, location?: { latitude: number; longitude: number; @@ -112,44 +121,17 @@ export interface GroupWalk { description: string; w3w: string; }; - start_location: { - latitude: number; - longitude: number; - grid_reference_6: string; - grid_reference_8: string; - postcode: string; - description: string; - w3w: string; - }; - meeting_location: { - latitude: number; - longitude: number; - grid_reference_6: string; - grid_reference_8: string; - postcode: string; - description: string; - w3w: string; - }; - end_location: { - latitude: number; - longitude: number; - grid_reference_6: string; - grid_reference_8: string; - postcode: string; - description: string; - w3w: string; - }; + start_location: LocationDetails; + meeting_location: LocationDetails; + end_location: LocationDetails; distance_km: number; distance_miles: number; ascent_feet: number; ascent_metres: number; - difficulty: { - code: string; - description: string; - }; + difficulty: Difficulty; shape: string; duration: number; - walk_leader: WalkLeader; + walk_leader: Contact; url: string; external_url: string; status: WalkStatus; @@ -255,3 +237,4 @@ export type WalkUploadRow = { } export const ALL_EVENT_TYPES: RamblersEventType[] = [RamblersEventType.GROUP_WALK, RamblersEventType.GROUP_EVENT, RamblersEventType.WELLBEING_WALK]; + diff --git a/projects/ngx-ramblers/src/app/models/walk.model.ts b/projects/ngx-ramblers/src/app/models/walk.model.ts index d69cfcc..3887733 100644 --- a/projects/ngx-ramblers/src/app/models/walk.model.ts +++ b/projects/ngx-ramblers/src/app/models/walk.model.ts @@ -8,7 +8,7 @@ import { WalkAccessMode } from "./walk-edit-mode.model"; import { WalkEventType } from "./walk-event-type.model"; import { WalkEvent } from "./walk-event.model"; import { WalkVenue } from "./walk-venue.model"; -import { Metadata, RamblersEventType, WalkLeader } from "./ramblers-walks-manager"; +import { Contact, Metadata, RamblersEventType } from "./ramblers-walks-manager"; import { HasMedia } from "./social-events.model"; export interface GoogleMapsConfig { @@ -117,7 +117,7 @@ export interface WalkLeaderIdsApiResponse extends ApiResponse { export interface WalkLeadersApiResponse extends ApiResponse { request: any; - response?: WalkLeader[]; + response?: Contact[]; } export enum WalkType { @@ -192,3 +192,11 @@ export interface FilterParameters extends FilterParametersSearch { selectType: number; ascending: boolean; } + +export interface LocalContact { + id?: string; + contactName?: string; + email?: string; + displayName?: string; + telephone?: string; +} diff --git a/projects/ngx-ramblers/src/app/modules/walks/walks-routing.module.ts b/projects/ngx-ramblers/src/app/modules/walks/walks-routing.module.ts index ad51b10..bd1de60 100644 --- a/projects/ngx-ramblers/src/app/modules/walks/walks-routing.module.ts +++ b/projects/ngx-ramblers/src/app/modules/walks/walks-routing.module.ts @@ -18,6 +18,7 @@ import { ActionButtonsComponent } from "../common/action-buttons/action-buttons" import { DynamicContentPageComponent } from "../common/dynamic-content-page/dynamic-content-page"; import { WalksModule } from "./walks.module"; import { WalksPopulationLocalGuard } from "../../guards/walks-population-local-guard"; +import { WalkImportComponent } from "../../pages/walks/walk-import/walk-import.component"; @NgModule({ imports: [WalksModule, RouterModule.forChild([ @@ -29,22 +30,27 @@ import { WalksPopulationLocalGuard } from "../../guards/walks-population-local-g { path: "admin", component: WalkAdminComponent, - canActivate: [WalksAuthGuard, WalksPopulationLocalGuard] + canActivate: [WalksAuthGuard] }, { path: "admin/add-walk-slots", component: WalkAddSlotsComponent, - canActivate: [WalksAuthGuard, WalksPopulationLocalGuard] + canActivate: [WalksAuthGuard] }, { path: "admin/export", component: WalkExportComponent, - canActivate: [WalksAuthGuard, WalksPopulationLocalGuard] + canActivate: [WalksAuthGuard] + }, + { + path: "admin/import", + component: WalkImportComponent, + canActivate: [WalksAuthGuard] }, { path: "admin/meetup-settings", component: WalkMeetupSettingsComponent, - canActivate: [WalksAuthGuard, WalksPopulationLocalGuard] + canActivate: [WalksAuthGuard] }, { path: "edit/:walk-id", diff --git a/projects/ngx-ramblers/src/app/modules/walks/walks.module.ts b/projects/ngx-ramblers/src/app/modules/walks/walks.module.ts index f035ea2..368cf71 100644 --- a/projects/ngx-ramblers/src/app/modules/walks/walks.module.ts +++ b/projects/ngx-ramblers/src/app/modules/walks/walks.module.ts @@ -79,6 +79,7 @@ import { SharedModule } from "../../shared-module"; import { WalkFeaturesComponent } from "../../pages/walks/walk-view/walk-features"; import { WalkFeatureComponent } from "../../pages/walks/walk-view/walk-feature"; import { WalkImagesComponent } from "../../pages/walks/walk-view/walk-images"; +import { WalkImportComponent } from "../../pages/walks/walk-import/walk-import.component"; @NgModule({ declarations: [ @@ -92,6 +93,7 @@ import { WalkImagesComponent } from "../../pages/walks/walk-view/walk-images"; WalkEditFullPageComponent, WalkEventTypePipe, WalkExportComponent, + WalkImportComponent, WalkFeatureComponent, WalkFeaturesComponent, WalkGroupComponent, diff --git a/projects/ngx-ramblers/src/app/pages/admin/data-population.service.ts b/projects/ngx-ramblers/src/app/pages/admin/data-population.service.ts index 3311334..2da2c0c 100644 --- a/projects/ngx-ramblers/src/app/pages/admin/data-population.service.ts +++ b/projects/ngx-ramblers/src/app/pages/admin/data-population.service.ts @@ -76,6 +76,14 @@ export class DataPopulationService { name: "mail-settings-process-mappings", text: "* Listed below are the built-in processes that are utilise email at points in the workflow. \n* This configuration page allows each of the built in processes to be linked to an Email Notification configuration.", category: "admin" + }, + { + name: "ramblers-import-help-page", + text: "This page should be used to prepare your group for when you wish to switch from using Walks Manager as your data source to your local database. There are several reasons why this can be beneficial, including providing better control over the walk leader information published on walks, email-backed workflow such as advertising walk slots and email notifications on change of walk details and the ability to provide more informative fields on the walk that are not supported by Walks Manager. The steps for using this page are to :\n" + + "* Click the **Collect importable walks from Walks Manager** button below to query all walks that are held in Walks manager. This data is then analysed for walk leaders and attempts are made to match them to existing members in your database.\n" + + "* Present statistics on the number of walks and walk leaders with an indication as to whether any missing members should be added.\n" + + "* Click the **Import And Save Walks Locally** button when you are happy with the proposed import information.", + category: "admin" } ]; const defaultContentTextItems = await Promise.all(defaultContent.map(async (contentText: ContentText) => await this.contentTextService.findOrCreateByNameAndCategory(contentText.name, contentText.category, contentText.text))); diff --git a/projects/ngx-ramblers/src/app/pages/admin/member-admin-modal/member-admin-modal.component.html b/projects/ngx-ramblers/src/app/pages/admin/member-admin-modal/member-admin-modal.component.html index 9aad578..b584db7 100644 --- a/projects/ngx-ramblers/src/app/pages/admin/member-admin-modal/member-admin-modal.component.html +++ b/projects/ngx-ramblers/src/app/pages/admin/member-admin-modal/member-admin-modal.component.html @@ -318,46 +318,18 @@
- +
+ class="form-control input-sm" id="ramblers-contact-name" + placeholder="The first name and last name used in Ramblers contact System">
-
+
-
-
- - -
-
-
diff --git a/projects/ngx-ramblers/src/app/pages/admin/member-admin-modal/member-admin-modal.component.ts b/projects/ngx-ramblers/src/app/pages/admin/member-admin-modal/member-admin-modal.component.ts index 95e7da8..919cc59 100644 --- a/projects/ngx-ramblers/src/app/pages/admin/member-admin-modal/member-admin-modal.component.ts +++ b/projects/ngx-ramblers/src/app/pages/admin/member-admin-modal/member-admin-modal.component.ts @@ -250,7 +250,7 @@ export class MemberAdminModalComponent implements OnInit, OnDestroy { this.notify.success("Existing Member copied! Make changes here and save to create new member."); } - defaultAssembleName() { + defaultContactName() { this.member.contactId = this.fullNameWithAliasPipe.transform(this.member); } } diff --git a/projects/ngx-ramblers/src/app/pages/admin/member-bulk-load/member-bulk-load.component.html b/projects/ngx-ramblers/src/app/pages/admin/member-bulk-load/member-bulk-load.component.html deleted file mode 100644 index 9743c15..0000000 --- a/projects/ngx-ramblers/src/app/pages/admin/member-bulk-load/member-bulk-load.component.html +++ /dev/null @@ -1,368 +0,0 @@ - -
-
-
- - -
-
-
-
-
- -
Member bulk load
-
-
-
-
    - The following data format is supported -
  • Since September 2020, Ramblers have switched to Using InsightHub for providing member data to Membership - Secretaries. The format that is compatible with Member Admin is Explore/Membership/Membership - Secretaries V4/FullList. - The file that needs to be downloaded is named Full List.xlsx
  • -
  • Click the New Upload Tab to continue. -
  • -
-
-
-
-
-
- -
-
-
-
-
    - To load the members, follow these steps: -
  • Download the Explore/Membership/Membership - Secretaries V4/FullList from InsightHub to a folder on your computer. -
  • -
  • Click the Browse for member import file button below, then navigate to the - downloaded file and then click Open. Alternatively you can drop the file on the Or - drop file here zone. -
  • -
  • The data will be loaded automatically. If the member does not match an existing member - based - on their membership number, a new member will be created with the - following fields populated: membership number, - forename, surname, postcode, private email, telephone, expiry date. -
  • -
  • If the member does match based on the membership number, the expiry date will be - updated. - Other fields will only be updated if they are blank. -
  • -
  • If all updates are performed successfully, {{this.systemConfig?.mailDefaults?.mailProvider | titlecase}} mailing list updates will be performed automatically.
  • -
  • If one or more errors occur during the Bulk Load, you can see the details of these errors by clicking on the Upload History tab, Choose Status 'Error' where you will be able to view the members that could not be imported.
  • -
-
-
- - -
- Or drop file here -
-
-
- - - - - - - - - - - - - - - - - -
NameSizeProgressUploaded
{{ item?.file?.name }}{{ item?.file?.size / 1024 / 1024 | number:'.2' }}MB - -
-
-
-
- - - -
-
-
-
-
-
-
- - {{notifyTarget.alertTitle}}: - {{notifyTarget.alertMessage}} -
-
-
-
-
-
-
- -
-
-
-
-
-

No Upload History Exists

-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
File upload information
Zip file: - -
Data file: - -
Uploaded by:
Uploaded on:
-
- - - - - - - - - - - - - -
StatusMessage
- -
-
-
- - - - - - - - - - - - - - - - - - - - - -
-
Membership - Number - -
-
-
Mobile Number - -
-
-
Email - -
-
-
First Name - -
-
-
Last Number - -
-
-
Postcode - -
-
{{member.membershipNumber}}{{member.mobileNumber}}{{member.email}}{{member.firstName}}{{member.lastName}}{{member.postcode}}
-
-
- - - - - - - - - - - - - - - - - - - - - - -
-
Update Time - -
-
-
Status - -
-
-
Row Number - -
-
-
Member Name - -
-
-
Changes - -
-
-
Audit Message - -
-
{{memberUpdateAudit.updateTime | displayDateAndTime}} - - {{memberUpdateAudit.rowNumber}}{{memberUpdateAudit.memberId || (memberUpdateAudit.member && memberUpdateAudit.member.id) | memberIdToFullName : members : '': true}}{{memberUpdateAudit.changes}}{{memberUpdateAudit.auditMessage}} - - Error Message: - -
- -
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/projects/ngx-ramblers/src/app/pages/admin/member-bulk-load/member-bulk-load.component.ts b/projects/ngx-ramblers/src/app/pages/admin/member-bulk-load/member-bulk-load.component.ts index e8d0c16..d6c0959 100644 --- a/projects/ngx-ramblers/src/app/pages/admin/member-bulk-load/member-bulk-load.component.ts +++ b/projects/ngx-ramblers/src/app/pages/admin/member-bulk-load/member-bulk-load.component.ts @@ -1,18 +1,7 @@ import { HttpErrorResponse } from "@angular/common/http"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, ParamMap } from "@angular/router"; -import { - faBan, - faCircleCheck, - faCircleInfo, - faCirclePlus, - faEnvelopesBulk, - faPencil, - faRemove, faSadTear, - faSearch, - faSpinner, - faThumbsUp -} from "@fortawesome/free-solid-svg-icons"; +import { faEnvelopesBulk, faSadTear, faSearch } from "@fortawesome/free-solid-svg-icons"; import first from "lodash-es/first"; import groupBy from "lodash-es/groupBy"; import map from "lodash-es/map"; @@ -25,7 +14,6 @@ import { Subject, Subscription } from "rxjs"; import { debounceTime, distinctUntilChanged } from "rxjs/operators"; import { AuthService } from "../../../auth/auth.service"; import { AlertTarget } from "../../../models/alert-target.model"; -import { FontAwesomeIcon } from "../../../models/images.model"; import { Member, MemberBulkLoadAudit, @@ -59,10 +47,386 @@ import { MailListUpdaterService } from "../../../services/mail/mail-list-updater import cloneDeep from "lodash-es/cloneDeep"; import { StringUtilsService } from "../../../services/string-utils.service"; import { MemberDefaultsService } from "../../../services/member/member-defaults.service"; +import { IconService } from "../../../services/icon-service/icon-service"; @Component({ selector: "app-bulk-load", - templateUrl: "./member-bulk-load.component.html", + template: ` + +
+
+
+ + +
+
+
+
+
+ +
Member bulk load
+
+
+
+
    + The following data format is supported +
  • Since September 2020, Ramblers have switched to Using InsightHub for providing member data to + Membership + Secretaries. The format that is compatible with Member Admin is Explore/Membership/Membership + Secretaries V4/FullList. + The file that needs to be downloaded is named Full List.xlsx
  • +
  • Click the New Upload Tab to continue. +
  • +
+
+
+
+
+
+ +
+
+
+
+
    + To load the members, follow these steps: +
  • Download the Explore/Membership/Membership + Secretaries V4/FullList from InsightHub to a folder on your computer. +
  • +
  • Click the Browse for member import file button below, then navigate to the + downloaded file and then click Open. Alternatively you can drop the file on the Or + drop file here zone. +
  • +
  • The data will be loaded automatically. If the member does not match an existing member + based + on their membership number, a new member will be created with the + following fields populated: membership number, + forename, surname, postcode, private email, telephone, expiry date. +
  • +
  • If the member does match based on the membership number, the expiry date will be + updated. + Other fields will only be updated if they are blank. +
  • +
  • If all updates are performed + successfully, {{ this.systemConfig?.mailDefaults?.mailProvider | titlecase }} mailing list + updates will be performed automatically. +
  • +
  • If one or more errors occur during the Bulk Load, you can see the details of these errors + by clicking on the Upload History tab, Choose Status 'Error' where you will be able + to view the members that could not be imported. +
  • +
+
+
+ + +
+ Or drop file here +
+
+
+ + + + + + + + + + + + + + + + + +
NameSizeProgressUploaded
{{ item?.file?.name }}{{ item?.file?.size / 1024 / 1024 | number:'.2' }}MB + +
+
+
+
+ + + +
+
+
+
+
+
+
+ + {{ notifyTarget.alertTitle }}: + {{ notifyTarget.alertMessage }} +
+
+
+
+
+
+
+ +
+
+
+
+
+

No Upload History Exists

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
File upload information
Zip file: + +
Data file: + +
Uploaded by:
Uploaded on:
+
+ + + + + + + + + + + + + +
StatusMessage
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
Membership + Number + +
+
+
Mobile Number + +
+
+
Email + +
+
+
First Name + +
+
+
Last Number + +
+
+
Postcode + +
+
{{ member.membershipNumber }}{{ member.mobileNumber }}{{ member.email }}{{ member.firstName }}{{ member.lastName }}{{ member.postcode }}
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
Update Time + +
+
+
Status + +
+
+
Row Number + +
+
+
Member Name + +
+
+
Changes + +
+
+
Audit Message + +
+
{{ memberUpdateAudit.updateTime | displayDateAndTime }} + + {{ memberUpdateAudit.rowNumber }}{{ memberUpdateAudit.memberId || (memberUpdateAudit.member && memberUpdateAudit.member.id) | memberIdToFullName : members : '': true }}{{ memberUpdateAudit.changes }}{{ memberUpdateAudit.auditMessage }} + + Error Message: + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
`, styleUrls: ["./member-bulk-load.component.sass", "../admin/admin.component.sass"] }) export class MemberBulkLoadComponent implements OnInit, OnDestroy { @@ -73,6 +437,7 @@ export class MemberBulkLoadComponent implements OnInit, OnDestroy { private memberBulkLoadService: MemberBulkLoadService, private memberService: MemberService, private searchFilterPipe: SearchFilterPipe, + protected icons: IconService, private memberUpdateAuditService: MemberUpdateAuditService, private memberDefaultsService: MemberDefaultsService, private memberBulkLoadAuditService: MemberBulkLoadAuditService, @@ -152,7 +517,8 @@ export class MemberBulkLoadComponent implements OnInit, OnDestroy { } else { const memberBulkLoadAuditApiResponse: MemberBulkLoadAuditApiResponse = JSON.parse(response); this.logger.debug("received response", memberBulkLoadAuditApiResponse); - this.memberBulkLoadService.processResponse(this.mailMessagingConfig, this.systemConfig, memberBulkLoadAuditApiResponse, this.members, this.notify) + const memberBulkLoadResponse = memberBulkLoadAuditApiResponse.response as MemberBulkLoadAudit; + this.memberBulkLoadService.processResponse(this.mailMessagingConfig, this.systemConfig, memberBulkLoadResponse, this.members, this.notify) .then(() => this.refreshMemberBulkLoadAudit()) .then(() => this.refreshMemberUpdateAudit()) .then(() => this.validateBulkUploadProcessing(memberBulkLoadAuditApiResponse)) @@ -261,33 +627,6 @@ export class MemberBulkLoadComponent implements OnInit, OnDestroy { return this.auditSummaryFormatted(this.auditSummary()); } - toFontAwesomeIcon(status: string): FontAwesomeIcon { - if (status === "cancelled") { - return {icon: faBan, class: "red-icon"}; - } - if (status === "created") { - return {icon: faCirclePlus, class: "green-icon"}; - } - if (status === "complete" || status === "summary") { - return {icon: faCircleCheck, class: "green-icon"}; - } - if (status === "success") { - return {icon: faCircleCheck, class: "green-icon"}; - } - if (status === "info") { - return {icon: faCircleInfo, class: "blue-icon"}; - } - if (status === "updated") { - return {icon: faPencil, class: "green-icon"}; - } - if (status === "error") { - return {icon: faRemove, class: "red-icon"}; - } - if (status === "skipped") { - return {icon: faThumbsUp, class: "green-icon"}; - } - } - applySortTo(field: string, filterSource: MemberTableFilter | MemberUpdateAuditTableFilter, unfilteredList: any[]) { this.logger.debug("sorting by field", field, "current value of filterSource", filterSource); filterSource.sortField = field; diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.html b/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.html deleted file mode 100644 index ef3b594..0000000 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.html +++ /dev/null @@ -1,96 +0,0 @@ - -
-
-
-

This facility allows you to add any number of walk slots to the programme that - will then entice walk leaders to come forward and lead. Please choose how you would like to create the - slots.

-
- - -
-
- - -
- -
-
-
-
    -
  • You can choose the date up until you want slots created using the calendar below.
  • -
  • An email can optionally be sent to the group informing them of the new slots that can now be - filled. -
  • -
- -
- - - -
-
-
-
    -
  • Use this option to create a slot on any day rather than just on a Sunday.
  • -
-
- - - -
-
-
-
- - {{notifyTarget.alertTitle}} {{notifyTarget.alertMessage}} -
-
- - - - - - - - - - - - -
-
-
-
diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.sass b/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.sass index 5ee663c..b34b11f 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.sass +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.sass @@ -1,5 +1,5 @@ .main-body - padding: 7px 0px 0px 32px + padding: 7px 0 0 32px .form-inline > * margin-right: 12px diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.ts b/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.ts index 1108182..b80e86b 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.ts +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-add-slots/walk-add-slots.component.ts @@ -20,10 +20,113 @@ import { DisplayDatesAndTimesPipe } from "../../../pipes/display-dates-and-times import uniq from "lodash-es/uniq"; import { DisplayDatesPipe } from "../../../pipes/display-dates.pipe"; import { RamblersEventType } from "../../../models/ramblers-walks-manager"; +import { SystemConfigService } from "../../../services/system/system-config.service"; +import { WalkDisplayService } from "../walk-display.service"; +import { StringUtilsService } from "../../../services/string-utils.service"; @Component({ selector: "app-walk-add-slots", - templateUrl: "./walk-add-slots.component.html", + template: ` + +
+
+
+

This facility allows you to add any number of walk slots to the programme that + will then entice walk leaders to come forward and lead. Please choose how you would like to create the + slots.

+
+ + +
+
+ + +
+ +
+
+
+
    +
  • You can choose the date up until you want slots created using the calendar below.
  • +
  • An email can optionally be sent to the group informing them of the new slots that can now be + filled. +
  • +
+ +
+ + + +
+
+
+
    +
  • Use this option to create a slot on any day rather than just on a Sunday.
  • +
+
+ + + +
+
+
+
+ + {{ notifyTarget.alertTitle }} {{ notifyTarget.alertMessage }} +
+
+ + + + + + + + + + + + +
+
+
+
+ `, styleUrls: ["./walk-add-slots.component.sass"] }) export class WalkAddSlotsComponent implements OnInit { @@ -47,7 +150,10 @@ export class WalkAddSlotsComponent implements OnInit { private displayDatesAndTimes: DisplayDatesAndTimesPipe, private walksQueryService: WalksQueryService, private dateUtils: DateUtilsService, + private stringUtils: StringUtilsService, private notifierService: NotifierService, + protected display: WalkDisplayService, + private systemConfigService: SystemConfigService, private broadcastService: BroadcastService, private urlService: UrlService, private walkEventService: WalkEventService, @@ -59,6 +165,15 @@ export class WalkAddSlotsComponent implements OnInit { ngOnInit() { this.logger.debug("ngOnInit"); this.notify = this.notifierService.createAlertInstance(this.notifyTarget); + this.systemConfigService.events().subscribe(async item => { + if (this.display.walkPopulationWalksManager()) { + this.notify.warning({ + title: "Create Walk Slots", + message: `This function is not available when the walk population is set to ${this.stringUtils.asTitle(this.display?.group?.walkPopulation)}` + }); + } else { + } + }); this.todayValue = this.dateUtils.momentNowNoTime().valueOf(); const momentUntil = this.dateUtils.momentNowNoTime().day(7 * 12); this.untilDate = this.dateUtils.asDateValue(momentUntil.valueOf()); @@ -188,8 +303,8 @@ export class WalkAddSlotsComponent implements OnInit { }); } - backToWalks() { - this.urlService.navigateTo(["walks"]); + backToWalksAdmin() { + this.urlService.navigateTo(["walks", "admin"]); } fixWalkDates() { diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.html b/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.html deleted file mode 100644 index 126fd1f..0000000 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.html +++ /dev/null @@ -1,27 +0,0 @@ - -
-
-
-
- -
Ramblers export
-
- -
-
-
- -
Add walk slots
-
- -
-
-
- -
Meetup settings
-
- -
-
-
-
diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.sass b/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.sass index c9109b2..c3f956d 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.sass +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.sass @@ -34,3 +34,6 @@ h5 text-align: center!important min-height: 200px padding: 12px 30px 30px + +.item-text + text-align: left!important diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.ts b/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.ts index fac64f5..1337c96 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.ts +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-admin/walk-admin.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from "@angular/core"; import { faMeetup } from "@fortawesome/free-brands-svg-icons"; -import { faCalendarPlus, faFileExport } from "@fortawesome/free-solid-svg-icons"; +import { faCalendarPlus, faFileExport, faFileImport } from "@fortawesome/free-solid-svg-icons"; import { NgxLoggerLevel } from "ngx-logger"; import { Subscription } from "rxjs"; import { AuthService } from "../../../auth/auth.service"; @@ -11,17 +11,58 @@ import { UrlService } from "../../../services/url.service"; @Component({ selector: "app-walk-admin", - templateUrl: "./walk-admin.component.html", + template: ` + +
+
+
+
+
+ +
Ramblers export
+
+ +
+
+
+
+
+ +
Ramblers walk import
+
+ +
+
+
+
+
+ +
Add walk slots
+
+ +
+
+
+
+
+ +
Meetup settings
+
+ +
+
+
+
+
+ `, styleUrls: ["./walk-admin.component.sass"], changeDetection: ChangeDetectionStrategy.Default }) export class WalkAdminComponent implements OnInit, OnDestroy { - allowAdminEdits: boolean; - private logger: Logger; - private subscriptions: Subscription[] = []; - faCalendarPlus = faCalendarPlus; - faFileExport = faFileExport; - faMeetup = faMeetup; constructor(private memberLoginService: MemberLoginService, private authService: AuthService, @@ -29,6 +70,14 @@ export class WalkAdminComponent implements OnInit, OnDestroy { loggerFactory: LoggerFactory) { this.logger = loggerFactory.createLogger(WalkAdminComponent, NgxLoggerLevel.OFF); } + allowAdminEdits: boolean; + private logger: Logger; + private subscriptions: Subscription[] = []; + faCalendarPlus = faCalendarPlus; + faFileExport = faFileExport; + faMeetup = faMeetup; + + protected readonly faFileImport = faFileImport; ngOnInit() { this.logger.debug("ngOnInit"); @@ -49,6 +98,10 @@ export class WalkAdminComponent implements OnInit, OnDestroy { this.urlService.navigateTo(["walks", "admin", "export"]); } + selectWalksForImport() { + this.urlService.navigateTo(["walks", "admin", "import"]); + } + addWalkSlots() { this.urlService.navigateTo(["walks", "admin", "add-walk-slots"]); } diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-display.service.spec.ts b/projects/ngx-ramblers/src/app/pages/walks/walk-display.service.spec.ts index 56aa571..f19cdfc 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-display.service.spec.ts +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-display.service.spec.ts @@ -16,6 +16,8 @@ import { WalksReferenceService } from "../../services/walks/walks-reference-data import { WalkDisplayService } from "./walk-display.service"; import { EventPopulation, Organisation } from "../../models/system.model"; import { RamblersEventType } from "../../models/ramblers-walks-manager"; +import { WalkEventService } from "../../services/walks/walk-event.service"; +import { EventType } from "../../models/walk.model"; const anyWalkDate = 123364; const walkLeaderMemberId = "walk-leader-id"; @@ -62,6 +64,7 @@ describe("WalkDisplayService", () => { MemberIdToFullNamePipe, ValueOrDefaultPipe, GoogleMapsService, + WalkEventService, {provide: MemberLoginService, useValue: memberLoginService}, {provide: "MemberAuditService", useValue: {}}, {provide: "WalkNotificationService", useValue: {}}, @@ -135,12 +138,22 @@ describe("WalkDisplayService", () => { spy = spyOn(memberLoginService, "allowWalkAdminEdits").and.returnValue(false); spy = spyOn(memberLoginService, "loggedInMember").and.returnValue({memberId: "leader-id"} as any); const service: WalkDisplayService = TestBed.inject(WalkDisplayService); - expect(service.toWalkAccessMode({ + const walkEventService = TestBed.inject(WalkEventService); + spyOn(service, "walkPopulationLocal").and.returnValue(true); + spyOn(walkEventService, "latestEvent").and.returnValue({ + eventType: EventType.APPROVED, + data: undefined, + date: 0, + memberId: "" + }); + const actual = service.toWalkAccessMode({ eventType: RamblersEventType.GROUP_WALK, walkLeaderMemberId: "another-walk-leader-id", events: dontCare, walkDate: anyWalkDate - })).toEqual(WalksReferenceService.walkAccessModes.view); + }); + console.log("actual", JSON.stringify(actual)); + expect(actual).toEqual(WalksReferenceService.walkAccessModes.view); }); }); diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-edit/walk-edit.component.html b/projects/ngx-ramblers/src/app/pages/walks/walk-edit/walk-edit.component.html index 90230cc..6ea77ed 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-edit/walk-edit.component.html +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-edit/walk-edit.component.html @@ -402,7 +402,7 @@
Google Maps
- + {{ walkExportTarget.alertMessage }}
-
+
- - - + + + +
@@ -135,7 +137,7 @@ import { StringUtilsService } from "../../../services/string-utils.service";
-
@@ -224,7 +226,7 @@ export class WalkExportComponent implements OnInit, OnDestroy { if (this.display.walkPopulationWalksManager()) { const message = { title: "Walks Export Initialisation", - message: "Walks cannot be exported from this view when the walk population is set to " + this.display?.group?.walkPopulation + message: `Walks cannot be exported from this view when the walk population is set to ${this.stringUtils.asTitle(this.display?.group?.walkPopulation)}` }; this.walkExportNotifier.warning(message); this.auditNotifier.warning(message); @@ -303,8 +305,8 @@ export class WalkExportComponent implements OnInit, OnDestroy { return this.ramblersWalksAndEventsService.selectedExportableWalks(this.walksForExport); } - navigateBackToWalks() { - this.urlService.navigateTo(["walks"]); + navigatebackToWalksAdmin() { + this.urlService.navigateTo(["walks", "admin"]); } populateWalkExport(walksForExport: WalkExport[]): WalkExport[] { diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-import/walk-import.component.ts b/projects/ngx-ramblers/src/app/pages/walks/walk-import/walk-import.component.ts new file mode 100644 index 0000000..e7f1179 --- /dev/null +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-import/walk-import.component.ts @@ -0,0 +1,212 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { faCircleInfo, faEye, faRemove } from "@fortawesome/free-solid-svg-icons"; +import { NgxLoggerLevel } from "ngx-logger"; +import { AlertTarget } from "../../../models/alert-target.model"; +import { Logger, LoggerFactory } from "../../../services/logger-factory.service"; +import { AlertInstance, NotifierService } from "../../../services/notifier.service"; +import { UrlService } from "../../../services/url.service"; +import { WalkDisplayService } from "../walk-display.service"; +import { SystemConfigService } from "../../../services/system/system-config.service"; +import { WalksImportService } from "../../../services/walks/walks-import.service"; +import { BulkLoadMemberAndMatchToWalks, WalksImportPreparation } from "../../../models/member.model"; +import sum from "lodash-es/sum"; +import { StringUtilsService } from "../../../services/string-utils.service"; +import { IconService } from "../../../services/icon-service/icon-service"; + +@Component({ + selector: "app-walk-import", + template: ` + +
+
+ +
+
+
+
+ + + + +
+
+
+
+ + + {{ alertTarget.alertTitle }}: {{ alertTarget.alertMessage }} +
+
+
+
+

Summary Import Information

+
+
+
    +
  • {{ message }}
  • +
+
+
+
+
+

Matching of Walks to Walk Leaders and Members

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Member ActionContact IdContact NameContact MobileWalks Matched
+ +
{{ bulkLoadMemberAndMatch.bulkLoadMemberAndMatch.memberAction }}
+
{{ bulkLoadMemberAndMatch.bulkLoadMemberAndMatch.contact?.id }}{{ bulkLoadMemberAndMatch.bulkLoadMemberAndMatch.member | fullNameWithAlias }}{{ bulkLoadMemberAndMatch.bulkLoadMemberAndMatch.member?.mobileNumber }}{{ bulkLoadMemberAndMatch.walks.length }}
{{ summary(walksImportPreparation.bulkLoadMembersAndMatchesToWalks) }}
+
+
+
` +}) + +export class WalkImportComponent implements OnInit, OnDestroy { + + constructor(private notifierService: NotifierService, + protected icons: IconService, + private systemConfigService: SystemConfigService, + public display: WalkDisplayService, + private walksImportService: WalksImportService, + private urlService: UrlService, + private stringUtilsService: StringUtilsService, + loggerFactory: LoggerFactory) { + this.logger = loggerFactory.createLogger("WalkImportComponent", NgxLoggerLevel.ERROR); + } + + private logger: Logger; + public walksImportPreparation: WalksImportPreparation; + public fileName: string; + public alertTarget: AlertTarget = {}; + private notify: AlertInstance; + public importInProgress = false; + faRemove = faRemove; + public messages: string[] = []; + public errorMessages: string[] = []; + + protected readonly faEye = faEye; + protected readonly faCircleInfo = faCircleInfo; + + ngOnInit() { + this.logger.debug("ngOnInit"); + this.notify = this.notifierService.createAlertInstance(this.alertTarget); + this.systemConfigService.events().subscribe(async item => { + if (this.display.walkPopulationLocal()) { + this.notify.warning({ + title: "Walks Import Initialisation", + message: "Walks cannot be imported from this view when the walk population is set to " + this.display?.group?.walkPopulation + }); + } else { + } + }); + } + + ngOnDestroy(): void { + } + + + navigateBackToAdmin() { + this.urlService.navigateTo(["walks", "admin"]); + } + + reset() { + this.messages = []; + this.walksImportPreparation = null; + } + + async collectAvailableWalks() { + this.messages = []; + this.importInProgress = true; + + this.notify.warning({ + title: "Walks Import Initialisation", + message: `Gathering walks and member date for matching` + }); + + this.walksImportService.prepareImport(this.messages) + .then(walksImportPreparation => { + this.walksImportPreparation = walksImportPreparation; + this.notify.success({ + title: "Walks Import Preparation Complete", + message: `See the table below for the list of members and the number of walks they are matched to` + }); + }) + .catch(error => this.notify.error({ + title: "Walks Import Initialisation Failed", + message: error + })) + .finally(() => this.importInProgress = false); + } + + importAndSaveWalksLocally() { + this.messages = []; + this.importInProgress = true; + this.notify.warning({ + title: "Walks Import Starting", + message: `Importing ${this.summary(this.walksImportPreparation.bulkLoadMembersAndMatchesToWalks)} walks` + }); + + this.walksImportService.performImport(this.walksImportPreparation, this.messages, this.notify) + .then((errorMessages: string[]) => { + this.errorMessages = errorMessages; + if (this?.errorMessages?.length > 0) { + this.notify.warning({ + title: "Walks Import Complete", + message: `Imported completed with ${this.stringUtilsService.pluraliseWithCount(this?.errorMessages?.length, "error")}. If you are happy with number of walks imported, Walk population should now be changed to Local in system settings.` + }); + + } else { + this.notify.success({ + title: "Walks Import Complete", + message: `Imported completed successfully. Walk population should now be changed to Local in system settings.` + }); + } + }) + .catch(error => this.notify.error({ + title: "Walks Import Failed", + message: error + })) + .finally(() => this.importInProgress = false); + } + + summary(bulkLoadMemberAndMatchToWalks: BulkLoadMemberAndMatchToWalks[]): number { + return sum(bulkLoadMemberAndMatchToWalks?.map(item => item?.walks?.length)); + } +} diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-list/walk-list.component.html b/projects/ngx-ramblers/src/app/pages/walks/walk-list/walk-list.component.html index d726648..5921e65 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-list/walk-list.component.html +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-list/walk-list.component.html @@ -8,10 +8,6 @@ (pageChanged)="pageChanged($event)"> -
diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-meetup-settings/walk-meetup-settings.component.html b/projects/ngx-ramblers/src/app/pages/walks/walk-meetup-settings/walk-meetup-settings.component.html index 9f863ac..cf5a106 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-meetup-settings/walk-meetup-settings.component.html +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-meetup-settings/walk-meetup-settings.component.html @@ -77,7 +77,7 @@

Meetup Settings -

diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-meetup-settings/walk-meetup-settings.component.ts b/projects/ngx-ramblers/src/app/pages/walks/walk-meetup-settings/walk-meetup-settings.component.ts index f35fa41..5a986d8 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-meetup-settings/walk-meetup-settings.component.ts +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-meetup-settings/walk-meetup-settings.component.ts @@ -63,8 +63,8 @@ export class WalkMeetupSettingsComponent implements OnInit { return this.tabs.tabs[tab].active; } - backToWalks() { - this.urlService.navigateTo(["walks"]); + backToWalksAdmin() { + this.urlService.navigateTo(["walks", "admin"]); } private replaceContent(contentText: ContentText) { diff --git a/projects/ngx-ramblers/src/app/pages/walks/walk-view/walk-view.ts b/projects/ngx-ramblers/src/app/pages/walks/walk-view/walk-view.ts index a07b26d..746778f 100644 --- a/projects/ngx-ramblers/src/app/pages/walks/walk-view/walk-view.ts +++ b/projects/ngx-ramblers/src/app/pages/walks/walk-view/walk-view.ts @@ -225,7 +225,7 @@ export class WalkViewComponent implements OnInit, OnDestroy { protected stringUtils = inject(StringUtilsService); private systemConfigService = inject(SystemConfigService); private notifierService = inject(NotifierService); - private logger = inject(LoggerFactory).createLogger("WalkViewComponent", NgxLoggerLevel.OFF); + private logger = inject(LoggerFactory).createLogger("WalkViewComponent", NgxLoggerLevel.ERROR); private notify: AlertInstance = this.notifierService.createAlertInstance(this.notifyTarget); @Input("displayedWalk") set init(displayedWalk: DisplayedWalk) { diff --git a/projects/ngx-ramblers/src/app/services/icon-service/icon-service.ts b/projects/ngx-ramblers/src/app/services/icon-service/icon-service.ts index b266c61..4f76c2e 100644 --- a/projects/ngx-ramblers/src/app/services/icon-service/icon-service.ts +++ b/projects/ngx-ramblers/src/app/services/icon-service/icon-service.ts @@ -4,6 +4,15 @@ import map from "lodash-es/map"; import { NgxLoggerLevel } from "ngx-logger"; import { KeyValue } from "../../functions/enums"; import { Logger, LoggerFactory } from "../logger-factory.service"; +import { FontAwesomeIcon } from "../../models/images.model"; +import { + faBan, + faCircleCheck, + faCircleInfo, + faCirclePlus, + faPencil, + faRemove, faThumbsUp +} from "@fortawesome/free-solid-svg-icons"; @Injectable({ providedIn: "root" @@ -17,7 +26,7 @@ export class IconService { constructor( loggerFactory: LoggerFactory) { - this.logger = loggerFactory.createLogger(IconService, NgxLoggerLevel.OFF); + this.logger = loggerFactory.createLogger(IconService, NgxLoggerLevel.WARN); this.iconArray = map(icons, (value, key) => ({key, value})); this.iconValues = this.iconArray.map(item => item.value); this.iconKeys = this.iconArray.map(item => item.key); @@ -31,4 +40,40 @@ export class IconService { return icon?.value; } } + + public toFontAwesomeIcon(status: string): FontAwesomeIcon { + if (status === "cancelled") { + return {icon: faBan, class: "red-icon"}; + } + if (status === "created") { + return {icon: faCirclePlus, class: "green-icon"}; + } + if (status === "complete" || status === "summary") { + return {icon: faCircleCheck, class: "green-icon"}; + } + if (status === "success") { + return {icon: faCircleCheck, class: "green-icon"}; + } + if (status === "found") { + return {icon: faCircleCheck, class: "green-icon"}; + } + if (status === "info") { + return {icon: faCircleInfo, class: "blue-icon"}; + } + if (status === "updated") { + return {icon: faPencil, class: "green-icon"}; + } + if (status === "error") { + return {icon: faRemove, class: "red-icon"}; + } + if (status === "not-found") { + return {icon: faRemove, class: "red-icon"}; + } + if (status === "skipped") { + return {icon: faThumbsUp, class: "green-icon"}; + } + this.logger.warn("no icon for status:", status); + return {icon: faCircleInfo, class: "blue-icon"}; + } + } diff --git a/projects/ngx-ramblers/src/app/services/member/member-bulk-load.service.ts b/projects/ngx-ramblers/src/app/services/member/member-bulk-load.service.ts index 5c657ca..e524177 100644 --- a/projects/ngx-ramblers/src/app/services/member/member-bulk-load.service.ts +++ b/projects/ngx-ramblers/src/app/services/member/member-bulk-load.service.ts @@ -1,5 +1,4 @@ import { Injectable } from "@angular/core"; -import each from "lodash-es/each"; import isEmpty from "lodash-es/isEmpty"; import omit from "lodash-es/omit"; import { NgxLoggerLevel } from "ngx-logger"; @@ -9,9 +8,9 @@ import { Member, MemberAction, MemberBulkLoadAudit, - MemberBulkLoadAuditApiResponse, MemberUpdateAudit, - RamblersMember + RamblersMember, + RamblersMemberAndContact } from "../../models/member.model"; import { DisplayDatePipe } from "../../pipes/display-date.pipe"; import { DateUtilsService } from "../date-utils.service"; @@ -24,6 +23,7 @@ import { MemberService } from "./member.service"; import { SystemConfig } from "../../models/system.model"; import { MailMessagingConfig } from "../../models/mail.model"; import { MemberDefaultsService } from "./member-defaults.service"; +import { NumberUtilsService } from "../number-utils.service"; @Injectable({ providedIn: "root" @@ -38,177 +38,212 @@ export class MemberBulkLoadService { private displayDate: DisplayDatePipe, private memberNamingService: MemberNamingService, private dateUtils: DateUtilsService, + private numberUtils: NumberUtilsService, loggerFactory: LoggerFactory) { this.logger = loggerFactory.createLogger(MemberBulkLoadService, NgxLoggerLevel.ERROR); } - processResponse(mailMessagingConfig: MailMessagingConfig, systemConfig: SystemConfig, apiResponse: MemberBulkLoadAuditApiResponse, existingMembers: Member[], notify: AlertInstance): Promise { + public processResponse(mailMessagingConfig: MailMessagingConfig, systemConfig: SystemConfig, memberBulkLoadResponse: MemberBulkLoadAudit, existingMembers: Member[], notify: AlertInstance): Promise { notify.setBusy(); - const today = this.dateUtils.momentNowNoTime().valueOf(); - const memberBulkLoadResponse = apiResponse.response as MemberBulkLoadAudit; - this.logger.info("processResponse:received", memberBulkLoadResponse); - - const processBulkLoadResponses = async (uploadSessionId: string) => { - const updatedPromises = []; - each(memberBulkLoadResponse.members, (ramblersMember, recordIndex) => { - createOrUpdateMember(uploadSessionId, recordIndex, ramblersMember, updatedPromises); + this.logger.info("processResponse:received", memberBulkLoadResponse.members.length, "ramblersMembers"); + return this.memberBulkLoadAuditService.create(memberBulkLoadResponse) + .then((auditResponse: MemberBulkLoadAudit) => { + const uploadSessionId = auditResponse.id; + return this.processBulkLoadResponses(mailMessagingConfig, systemConfig, uploadSessionId, memberBulkLoadResponse.members, existingMembers, notify); }); - await Promise.all(updatedPromises); - this.logger.info("performed total of", updatedPromises.length, "audit or member updates"); - return updatedPromises; - }; - - const saveAndAuditMemberUpdate = (promises: Promise[], uploadSessionId: string, rowNumber: number, memberAction: MemberAction, changes: number, auditMessage: any, member: Member) => { - - const audit: MemberUpdateAudit = { - uploadSessionId, - updateTime: this.dateUtils.nowAsValue(), - memberAction, - rowNumber, - changes, - auditMessage - }; - - const qualifier = "for membership " + member.membershipNumber; - return this.memberService.createOrUpdate(member) - .then(savedMember => { - audit.memberId = savedMember.id; - notify.success({title: "Bulk member load " + qualifier + " was successful", message: auditMessage}); - this.logger.info("saveAndAuditMemberUpdate:", audit); - promises.push(this.memberUpdateAuditService.create(audit)); - return promises; - }).catch(response => { - this.logger.warn("member save error for member:", member, "response:", response); - audit.member = member; - audit.memberAction = MemberAction.error; - this.logger.warn("member was not saved, so saving it to audit:", audit); - notify.warning({title: "Bulk member load " + qualifier + " failed", message: auditMessage}); - audit.auditErrorMessage = omit(response.error, "request"); - promises.push(this.memberUpdateAuditService.create(audit)); - return promises; - }); - }; + } - const convertMembershipExpiryDate = (ramblersMember: RamblersMember): number | string => { - const dataValue = !isEmpty(ramblersMember?.membershipExpiryDate) ? this.dateUtils.asValueNoTime(ramblersMember.membershipExpiryDate, "DD/MM/YYYY") : ramblersMember.membershipExpiryDate; - this.logger.info("ramblersMember", ramblersMember, "membershipExpiryDate", ramblersMember.membershipExpiryDate, "->", this.dateUtils.displayDate(dataValue)); - return dataValue; + public bulkLoadMemberAndMatchFor(ramblersMemberAndContact: RamblersMemberAndContact, existingMembers: Member[], systemConfig: SystemConfig): BulkLoadMemberAndMatch { + const today = this.dateUtils.momentNowNoTime().valueOf(); + const ramblersMember = ramblersMemberAndContact.ramblersMember; + ramblersMember.membershipExpiryDate = this.convertMembershipExpiryDate(ramblersMember); + ramblersMember.groupMember = !ramblersMember.membershipExpiryDate || ramblersMember.membershipExpiryDate >= today; + const contactMatchingEnabled: boolean = !!ramblersMemberAndContact?.contact; + const bulkLoadMemberAndMatch: BulkLoadMemberAndMatch = { + memberAction: null, + member: null, + memberMatchType: null, + ramblersMember, + contact: ramblersMemberAndContact?.contact }; - - const queryOrCreateBulkLoadMemberAndMatch = (ramblersMember: RamblersMember): BulkLoadMemberAndMatch => { - ramblersMember.membershipExpiryDate = convertMembershipExpiryDate(ramblersMember); - ramblersMember.groupMember = !ramblersMember.membershipExpiryDate || ramblersMember.membershipExpiryDate >= today; - const bulkLoadMemberAndMatch: BulkLoadMemberAndMatch = {memberAction: null, member: null, memberMatchType: null}; - bulkLoadMemberAndMatch.member = existingMembers.find(member => { - const membershipNumberMatch = member?.membershipNumber === ramblersMember?.membershipNumber; - if (membershipNumberMatch) { - bulkLoadMemberAndMatch.memberMatchType = "membership number"; - return true; - } else if (!isEmpty(ramblersMember.email) && !isEmpty(member.email) && ramblersMember.email === member.email && ramblersMember.lastName === member.lastName) { - bulkLoadMemberAndMatch.memberMatchType = "email and last name"; - return true; - } else { - return false; - } - }); - if (bulkLoadMemberAndMatch.member) { - this.logger.info("matched members based on:", bulkLoadMemberAndMatch.memberMatchType, - "ramblersMember:", ramblersMember, - "member:", bulkLoadMemberAndMatch.member); - this.memberDefaultsService.resetUpdateStatusForMember(bulkLoadMemberAndMatch.member, systemConfig); + bulkLoadMemberAndMatch.member = existingMembers.find(member => { + if (member?.membershipNumber === ramblersMember?.membershipNumber) { + bulkLoadMemberAndMatch.memberMatchType = "membership number"; + bulkLoadMemberAndMatch.memberAction = MemberAction.found; + return true; + } else if (!isEmpty(ramblersMember.email) && !isEmpty(member.email) && ramblersMember.email === member.email && ramblersMember.lastName === member.lastName) { + bulkLoadMemberAndMatch.memberMatchType = "email and last name"; + bulkLoadMemberAndMatch.memberAction = MemberAction.found; + return true; + } else if (contactMatchingEnabled && !isEmpty(ramblersMember.mobileNumber) && !isEmpty(member.mobileNumber) && this.numberUtils.asNumber(ramblersMember.mobileNumber) === this.numberUtils.asNumber(member.mobileNumber)) { + bulkLoadMemberAndMatch.memberMatchType = "mobile number"; + bulkLoadMemberAndMatch.memberAction = MemberAction.found; + return true; + } else if (contactMatchingEnabled && this.memberNamingService.removeCharactersNotPartOfName(ramblersMemberAndContact.contact.name) === this.memberNamingService.removeCharactersNotPartOfName(member.displayName)) { + bulkLoadMemberAndMatch.memberMatchType = "display name"; + bulkLoadMemberAndMatch.memberAction = MemberAction.found; + return true; } else { - bulkLoadMemberAndMatch.memberAction = MemberAction.created; - bulkLoadMemberAndMatch.member = { - firstName: null, - lastName: null, - groupMember: true, - socialMember: true, - userName: this.memberNamingService.createUniqueUserName(ramblersMember, existingMembers), - displayName: this.memberNamingService.createUniqueDisplayName(ramblersMember, existingMembers), - expiredPassword: true - }; - this.logger.info("new member created:", bulkLoadMemberAndMatch.member); + return false; } - return bulkLoadMemberAndMatch; - }; - - const createOrUpdateMember = (uploadSessionId: string, recordIndex: number, ramblersMember: RamblersMember, promises: any[]) => { - const bulkLoadMemberAndMatch: BulkLoadMemberAndMatch = queryOrCreateBulkLoadMemberAndMatch(ramblersMember); - const updateAudit = {auditMessages: [], fieldsChanged: 0, fieldsSkipped: 0}; - each([ - {fieldName: "membershipExpiryDate", writeDataIf: "changed", type: "date"}, - {fieldName: "membershipNumber", writeDataIf: "changed", type: "string"}, - {fieldName: "mobileNumber", writeDataIf: "empty", type: "string"}, - {fieldName: "email", writeDataIf: "empty", type: "string"}, - {fieldName: "firstName", writeDataIf: "empty", type: "string"}, - {fieldName: "lastName", writeDataIf: "empty", type: "string"}, - {fieldName: "postcode", writeDataIf: "empty", type: "string"}, - {fieldName: "groupMember", writeDataIf: "not-revoked", type: "boolean"}], field => { - changeAndAuditMemberField(updateAudit, bulkLoadMemberAndMatch.member, ramblersMember, field); - if (bulkLoadMemberAndMatch.memberAction === MemberAction.created) { - this.memberDefaultsService.applyDefaultMailSettingsToMember(bulkLoadMemberAndMatch.member, systemConfig, mailMessagingConfig); - } - }); - this.logger.info("saveAndAuditMemberUpdate -> member:", bulkLoadMemberAndMatch.member, "updateAudit:", updateAudit); - return saveAndAuditMemberUpdate(promises, uploadSessionId, recordIndex + 1, bulkLoadMemberAndMatch.memberAction || (updateAudit.fieldsChanged > 0 ? MemberAction.updated : MemberAction.skipped), updateAudit.fieldsChanged, updateAudit.auditMessages.join(", "), bulkLoadMemberAndMatch.member); - - }; - - const changeAndAuditMemberField = (updateAudit: { - fieldsChanged: number; fieldsSkipped: number; - auditMessages: any[] - }, member: Member, ramblersMember: RamblersMember, auditField: AuditField) => { - - const auditValueForType = (field: AuditField, source: object) => { - const dataValue = source[field.fieldName]; - switch (field.type) { - case "date": - return this.displayDate.transform(dataValue || "(none)"); - case "boolean": - return dataValue || false; - default: - return dataValue || "(none)"; - } + }); + if (bulkLoadMemberAndMatch.member) { + this.logger.info("matched ramblersMembers based on:", bulkLoadMemberAndMatch.memberMatchType, + "contact:", contactMatchingEnabled, + "ramblersMember:", ramblersMember, + "member:", bulkLoadMemberAndMatch.member); + this.memberDefaultsService.resetUpdateStatusForMember(bulkLoadMemberAndMatch.member, systemConfig); + } else { + bulkLoadMemberAndMatch.memberAction = MemberAction.created; + const displayName = this.memberNamingService.createUniqueDisplayName(ramblersMember, existingMembers); + bulkLoadMemberAndMatch.member = { + firstName: null, + lastName: null, + groupMember: true, + socialMember: true, + userName: this.memberNamingService.createUniqueUserName(ramblersMember, existingMembers), + displayName, + contactId: displayName, + expiredPassword: true }; - - const fieldName = auditField.fieldName; - let performMemberUpdate = false; - let auditQualifier = " not overwritten with "; - let auditMessage: string; - const oldValue = auditValueForType(auditField, member); - const newValue = auditValueForType(auditField, ramblersMember); - const dataDifferent: boolean = oldValue.toString() !== newValue.toString(); - if (auditField.writeDataIf === "changed") { - performMemberUpdate = dataDifferent && ramblersMember[fieldName]; - } else if (auditField.writeDataIf === "empty") { - performMemberUpdate = !member[fieldName]; - } else if (auditField.writeDataIf === "not-revoked") { - performMemberUpdate = newValue && dataDifferent && !member.revoked; - } else if (auditField.writeDataIf) { - performMemberUpdate = !!newValue; - } - if (performMemberUpdate) { - auditQualifier = " updated to "; - member[fieldName] = ramblersMember[fieldName]; - updateAudit.fieldsChanged++; - } - if (dataDifferent) { - if (!performMemberUpdate) { - updateAudit.fieldsSkipped++; - } - auditMessage = fieldName + ": " + oldValue + auditQualifier + newValue; - } - if ((performMemberUpdate || dataDifferent) && auditMessage) { - updateAudit.auditMessages.push(auditMessage); + if (contactMatchingEnabled) { + bulkLoadMemberAndMatch.member.firstName = this.memberNamingService.removeCharactersNotPartOfName(ramblersMember.firstName) || "Unknown"; + bulkLoadMemberAndMatch.member.lastName = this.memberNamingService.removeCharactersNotPartOfName(ramblersMember.lastName) || "Unknown"; + bulkLoadMemberAndMatch.member.mobileNumber = ramblersMember.mobileNumber; } + existingMembers.push(bulkLoadMemberAndMatch.member); + this.logger.info("new member created:", bulkLoadMemberAndMatch.member); + } + return bulkLoadMemberAndMatch; + }; + + private saveAndAuditMemberUpdate(promises: Promise[], uploadSessionId: string, rowNumber: number, memberAction: MemberAction, changes: number, auditMessage: any, member: Member, notify: AlertInstance): Promise[]> { + + const audit: MemberUpdateAudit = { + uploadSessionId, + updateTime: this.dateUtils.nowAsValue(), + memberAction, + rowNumber, + changes, + auditMessage }; - return this.memberBulkLoadAuditService.create(memberBulkLoadResponse) - .then((auditResponse: MemberBulkLoadAudit) => { - const uploadSessionId = auditResponse.id; - return processBulkLoadResponses(uploadSessionId); + const qualifier = `for membership ${member.membershipNumber}`; + + return this.memberService.createOrUpdate(member) + .then((savedMember: Member) => { + audit.memberId = savedMember.id; + notify.success({title: `Bulk member load ${qualifier} was successful`, message: auditMessage}); + this.logger.info("saveAndAuditMemberUpdate:", audit); + promises.push(this.memberUpdateAuditService.create(audit)); + return promises; + }).catch(response => { + this.logger.warn("member save error for member:", member, "response:", response); + audit.member = member; + audit.memberAction = MemberAction.error; + this.logger.warn("member was not saved, so saving it to audit:", audit); + notify.warning({title: `Bulk member load ${qualifier} failed`, message: auditMessage}); + audit.auditErrorMessage = omit(response.error, "request"); + promises.push(this.memberUpdateAuditService.create(audit)); + return promises; }); - - } + }; + + private convertMembershipExpiryDate(ramblersMember: RamblersMember): number | string { + const dataValue = !isEmpty(ramblersMember?.membershipExpiryDate) ? this.dateUtils.asValueNoTime(ramblersMember.membershipExpiryDate, "DD/MM/YYYY") : ramblersMember.membershipExpiryDate; + this.logger.info("ramblersMember", ramblersMember, "membershipExpiryDate", ramblersMember.membershipExpiryDate, "->", this.dateUtils.displayDate(dataValue)); + return dataValue; + }; + + + private auditValueForType(field: AuditField, source: object) { + const dataValue = source[field.fieldName]; + switch (field.type) { + case "date": + return this.displayDate.transform(dataValue || "(none)"); + case "boolean": + return dataValue || false; + default: + return dataValue || "(none)"; + } + }; + + private changeAndAuditMemberField(updateAudit: { + fieldsChanged: number; + fieldsSkipped: number; + auditMessages: any[] + }, member: Member, ramblersMember: RamblersMember, auditField: AuditField) { + + + const fieldName = auditField.fieldName; + let performMemberUpdate = false; + let auditQualifier = " not overwritten with "; + let auditMessage: string; + const oldValue = this.auditValueForType(auditField, member); + const newValue = this.auditValueForType(auditField, ramblersMember); + const dataDifferent: boolean = oldValue.toString() !== newValue.toString(); + if (auditField.writeDataIf === "changed") { + performMemberUpdate = dataDifferent && ramblersMember[fieldName]; + } else if (auditField.writeDataIf === "empty") { + performMemberUpdate = !member[fieldName]; + } else if (auditField.writeDataIf === "not-revoked") { + performMemberUpdate = newValue && dataDifferent && !member.revoked; + } else if (auditField.writeDataIf) { + performMemberUpdate = !!newValue; + } + if (performMemberUpdate) { + auditQualifier = " updated to "; + member[fieldName] = ramblersMember[fieldName]; + updateAudit.fieldsChanged++; + } + if (dataDifferent) { + if (!performMemberUpdate) { + updateAudit.fieldsSkipped++; + } + auditMessage = `${fieldName}: ${oldValue}${auditQualifier}${newValue}`; + } + if ((performMemberUpdate || dataDifferent) && auditMessage) { + updateAudit.auditMessages.push(auditMessage); + } + }; + + private createOrUpdateMember(mailMessagingConfig: MailMessagingConfig, systemConfig: SystemConfig, uploadSessionId: string, recordIndex: number, ramblersMember: RamblersMember, promises: any[], existingMembers: Member[], notify: AlertInstance): Promise { + const ramblersMemberAndContactNotUsingContactMatching: RamblersMemberAndContact = { + ramblersMember, + contact: null + }; + const bulkLoadMemberAndMatch: BulkLoadMemberAndMatch = this.bulkLoadMemberAndMatchFor(ramblersMemberAndContactNotUsingContactMatching, existingMembers, systemConfig); + const updateAudit = {auditMessages: [], fieldsChanged: 0, fieldsSkipped: 0}; + [ + {fieldName: "membershipExpiryDate", writeDataIf: "changed", type: "date"}, + {fieldName: "membershipNumber", writeDataIf: "changed", type: "string"}, + {fieldName: "mobileNumber", writeDataIf: "empty", type: "string"}, + {fieldName: "email", writeDataIf: "empty", type: "string"}, + {fieldName: "firstName", writeDataIf: "empty", type: "string"}, + {fieldName: "lastName", writeDataIf: "empty", type: "string"}, + {fieldName: "postcode", writeDataIf: "empty", type: "string"}, + {fieldName: "groupMember", writeDataIf: "not-revoked", type: "boolean"} + ].forEach((field: AuditField) => { + this.changeAndAuditMemberField(updateAudit, bulkLoadMemberAndMatch.member, ramblersMember, field); + if (bulkLoadMemberAndMatch.memberAction === MemberAction.created) { + this.memberDefaultsService.applyDefaultMailSettingsToMember(bulkLoadMemberAndMatch.member, systemConfig, mailMessagingConfig); + } + }); + this.logger.info("saveAndAuditMemberUpdate -> member:", bulkLoadMemberAndMatch.member, "updateAudit:", updateAudit); + const memberAction = bulkLoadMemberAndMatch.memberAction || (updateAudit.fieldsChanged > 0 ? MemberAction.updated : MemberAction.skipped); + return this.saveAndAuditMemberUpdate(promises, uploadSessionId, recordIndex + 1, memberAction, updateAudit.fieldsChanged, updateAudit.auditMessages.join(", "), bulkLoadMemberAndMatch.member, notify); + + }; + + private async processBulkLoadResponses(mailMessagingConfig: MailMessagingConfig, systemConfig: SystemConfig, uploadSessionId: string, ramblersMembers: RamblersMember[], existingMembers: Member[], notify: AlertInstance) { + const updatedPromises = []; + ramblersMembers.map(ramblersMember => { + const recordIndex = ramblersMembers.indexOf(ramblersMember); + this.createOrUpdateMember(mailMessagingConfig, systemConfig, uploadSessionId, recordIndex, ramblersMember, updatedPromises, existingMembers, notify); + }); + await Promise.all(updatedPromises); + this.logger.info("performed total of", updatedPromises.length, "audit or member updates"); + return updatedPromises; + }; } diff --git a/projects/ngx-ramblers/src/app/services/member/member-naming.service.spec.ts b/projects/ngx-ramblers/src/app/services/member/member-naming.service.spec.ts index e87b48a..eb54c0b 100644 --- a/projects/ngx-ramblers/src/app/services/member/member-naming.service.spec.ts +++ b/projects/ngx-ramblers/src/app/services/member/member-naming.service.spec.ts @@ -6,6 +6,7 @@ import { FullNameWithAliasPipe } from "../../pipes/full-name-with-alias.pipe"; import { FullNamePipe } from "../../pipes/full-name.pipe"; import { MemberIdToFullNamePipe } from "../../pipes/member-id-to-full-name.pipe"; import { MemberNamingService } from "./member-naming.service"; +import { Member } from "../../models/member.model"; const twoJohns = [{ firstName: "John", @@ -19,24 +20,60 @@ const twoJohns = [{ displayName: "John G1" }]; +const withDot = { + firstName: "Carol", + lastName: "M.", + userName: "Carol.m", + displayName: "John M" +}; + describe("MemberNamingService", () => { - beforeEach(() => TestBed.configureTestingModule({ - imports: [LoggerTestingModule, HttpClientTestingModule, RouterTestingModule], - providers: [MemberIdToFullNamePipe, - FullNamePipe, - FullNameWithAliasPipe] - })); + let service: MemberNamingService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [LoggerTestingModule, HttpClientTestingModule, RouterTestingModule], + providers: [MemberIdToFullNamePipe, FullNamePipe, FullNameWithAliasPipe] + }); + service = TestBed.inject(MemberNamingService); + }); + + it("should create display name without trailing dots", () => { + const member: Member = { firstName: "Carol", lastName: "M." } as Member; + const displayName = service.createDisplayNameFromMember(member); + expect(displayName).toBe("Carol M"); + }); + + it("should create display name without trailing dots for empty last name", () => { + const member: Member = { firstName: "Carol", lastName: "" } as Member; + const displayName = service.createDisplayNameFromMember(member); + expect(displayName).toBe("Carol"); + }); + + it("should create display name without trailing dots for empty first name", () => { + const member: Member = { firstName: "", lastName: "M." } as Member; + const displayName = service.createDisplayNameFromMember(member); + expect(displayName).toBe("M"); + }); + + it("should create display name without trailing dots for both names empty", () => { + const member: Member = { firstName: "", lastName: "" } as Member; + const displayName = service.createDisplayNameFromMember(member); + expect(displayName).toBe(""); + }); it("createUniqueUserName should generate the next available username were none exist already", () => { - const service: MemberNamingService = TestBed.inject(MemberNamingService); expect(service.createUniqueUserName({ firstName: "John", lastName: "Grant" }, [])).toEqual("john.grant"); }); + it("createUniqueUserName should generate the names without dots if supplied", () => { + expect(service.createUniqueUserName(withDot, [])).toEqual("carol.m"); + }); + it("createUniqueUserName should generate the next available username were some exist already", () => { - const service: MemberNamingService = TestBed.inject(MemberNamingService); expect(service.createUniqueUserName({ firstName: "John", lastName: "Grant" @@ -44,7 +81,6 @@ describe("MemberNamingService", () => { }); it("createDisplayName should generate the next available display name were some exist already", () => { - const service: MemberNamingService = TestBed.inject(MemberNamingService); expect(service.createUniqueDisplayName({ firstName: "John", lastName: "Grant" @@ -52,28 +88,125 @@ describe("MemberNamingService", () => { }); it("createDisplayName should generate the next available display name were none exist already", () => { - const service: MemberNamingService = TestBed.inject(MemberNamingService); expect(service.createUniqueDisplayName({ firstName: "John", lastName: "Grant" }, [])).toEqual("John G"); }); - it("firstAndLastNameFrom should generate a nam from a single string input", () => { - const service: MemberNamingService = TestBed.inject(MemberNamingService); - expect(service.firstAndLastNameFrom("John Grant")).toEqual({firstName: "John", lastName: "Grant"}); + it("firstAndLastNameFrom should generate a name from a single string input", () => { + expect(service.firstAndLastNameFrom("John Grant")).toEqual({ firstName: "John", lastName: "Grant" }); }); - it("firstAndLastNameFrom should accept a hyphenated a name", () => { - const service: MemberNamingService = TestBed.inject(MemberNamingService); + it("firstAndLastNameFrom should accept a hyphenated name", () => { expect(service.firstAndLastNameFrom("John Hyphenated-Grant")).toEqual({ firstName: "John", lastName: "Hyphenated-Grant" }); }); - it("firstAndLastNameFrom should accept a hyphenated a name", () => { - const service: MemberNamingService = TestBed.inject(MemberNamingService); + it("firstAndLastNameFrom should return null for null input", () => { expect(service.firstAndLastNameFrom(null)).toEqual(null); }); -}) + + it("should remove spaces and trailing dots", () => { + const result = service.removeCharactersNotPartOfName("carol m."); + expect(result).toBe("carol m"); + }); + + it("should remove spaces and trailing dots and spaces", () => { + const result = service.removeCharactersNotPartOfName("carol m. "); + expect(result).toBe("carol m"); + }); + + it("should remove only trailing dots", () => { + const result = service.removeCharactersNotPartOfName("carolm."); + expect(result).toBe("carolm"); + }); + + it("should remove only spaces", () => { + const result = service.removeCharactersNotPartOfName("carol m"); + expect(result).toBe("carol m"); + }); + + it("should return the same string if no spaces or trailing dots", () => { + const result = service.removeCharactersNotPartOfName("carolm"); + expect(result).toBe("carolm"); + }); + + it("should handle empty string", () => { + const result = service.removeCharactersNotPartOfName(""); + expect(result).toBe(""); + }); + + it("should handle null value", () => { + const result = service.removeCharactersNotPartOfName(null); + expect(result).toBe(""); + }); +}); + +describe("MemberNamingService - createUserName", () => { + let service: MemberNamingService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [LoggerTestingModule, HttpClientTestingModule, RouterTestingModule], + providers: [MemberIdToFullNamePipe, FullNamePipe, FullNameWithAliasPipe] + }); + service = TestBed.inject(MemberNamingService); + }); + + it("should create username with both first and last names", () => { + const member: Member = { firstName: "John", lastName: "Doe" } as Member; + const userName = service.createUserName(member); + expect(userName).toBe("john.doe"); + }); + + it("should create username with only first name", () => { + const member: Member = { firstName: "John", lastName: "" } as Member; + const userName = service.createUserName(member); + expect(userName).toBe("john"); + }); + + it("should create username with only last name", () => { + const member: Member = { firstName: "", lastName: "Doe" } as Member; + const userName = service.createUserName(member); + expect(userName).toBe("doe"); + }); + + it("should create empty username when both names are empty", () => { + const member: Member = { firstName: "", lastName: "" } as Member; + const userName = service.createUserName(member); + expect(userName).toBe(""); + }); + + it("should create username with both names and remove trailing dots and spaces", () => { + const member: Member = { firstName: "John ", lastName: "Doe. " } as Member; + const userName = service.createUserName(member); + expect(userName).toBe("john.doe"); + }); + + it("should create username with only first name and remove trailing dots and spaces", () => { + const member: Member = { firstName: "John. ", lastName: "" } as Member; + const userName = service.createUserName(member); + expect(userName).toBe("john"); + }); + + it("should create username with only first name and lastname missing remove trailing dots and spaces", () => { + const member: Member = { firstName: "John. " } as Member; + const userName = service.createUserName(member); + expect(userName).toBe("john"); + }); + + it("should create username with only lastname name and firstname missing remove trailing dots and spaces", () => { + const member: Member = { lastName: "Doe. " } as Member; + const userName = service.createUserName(member); + expect(userName).toBe("doe"); + }); + + it("should create username with only last name and remove trailing dots and spaces", () => { + const member: Member = { firstName: "", lastName: "Doe. " } as Member; + const userName = service.createUserName(member); + expect(userName).toBe("doe"); + }); +}); diff --git a/projects/ngx-ramblers/src/app/services/member/member-naming.service.ts b/projects/ngx-ramblers/src/app/services/member/member-naming.service.ts index 13817fd..c067568 100644 --- a/projects/ngx-ramblers/src/app/services/member/member-naming.service.ts +++ b/projects/ngx-ramblers/src/app/services/member/member-naming.service.ts @@ -3,7 +3,6 @@ import first from "lodash-es/first"; import { NgxLoggerLevel } from "ngx-logger"; import { FirstAndLastName, HasEmailFirstAndLastName, Member, RamblersMember } from "../../models/member.model"; import { Logger, LoggerFactory } from "../logger-factory.service"; -import { StringUtilsService } from "../string-utils.service"; @Injectable({ providedIn: "root" @@ -11,7 +10,7 @@ import { StringUtilsService } from "../string-utils.service"; export class MemberNamingService { private logger: Logger; - constructor(private stringUtils: StringUtilsService, loggerFactory: LoggerFactory) { + constructor(loggerFactory: LoggerFactory) { this.logger = loggerFactory.createLogger(MemberNamingService, NgxLoggerLevel.OFF); } @@ -43,7 +42,7 @@ export class MemberNamingService { return this.createUniqueValueFrom(this.createDisplayNameFromMember(member), "displayName", members); } - createUniqueValueFrom(value: string, field: string, members: Member[]) { + public createUniqueValueFrom(value: string, field: string, members: Member[]) { let attempts = 0; while (true) { const createdName = value + (attempts === 0 ? "" : attempts); @@ -55,18 +54,24 @@ export class MemberNamingService { } } - memberFieldExists(field: string, value: string, members: Member[]) { - const member = members.find(member => member[field] === value); - const returnValue = member && member[field]; - this.logger.debug("field", field, "matching", value, member, "->", returnValue); - return returnValue; + public createUserName(member: RamblersMember | Member): string { + if (member?.firstName && member?.lastName) { + const userName = `${member.firstName.trim()}.${member.lastName.trim()}`.toLowerCase(); + return this.removeCharactersNotPartOfName(userName); + } else if (member?.firstName) { + return this.removeCharactersNotPartOfName(member.firstName.trim().toLowerCase()); + } else if (member?.lastName) { + return this.removeCharactersNotPartOfName(member.lastName.trim().toLowerCase()); + } else { + return ""; + } } - private createUserName(member: RamblersMember | Member): string { - return member?.firstName && member?.lastName ? this.stringUtils.replaceAll(" ", "", (`${member.firstName}.${member.lastName}`).toLowerCase()) as string : ""; + public removeCharactersNotPartOfName(value: string): string { + return value ? value.replace(/\s+$/, "").replace(/\.$/, "").trim() : ""; } - private createDisplayNameFromMember(member: Member): string { + public createDisplayNameFromMember(member: Member): string { const lastName = member.lastName; const firstName = member.firstName; return this.createDisplayName(firstName, lastName); @@ -76,4 +81,11 @@ export class MemberNamingService { return (`${(firstName || "").trim()} ${(lastName || "").trim().substring(0, 1).toUpperCase()}`).trim(); } + private memberFieldExists(field: string, value: string, members: Member[]) { + const member = members.find(member => member[field] === value); + const returnValue = member && member[field]; + this.logger.debug("field", field, "matching", value, member, "->", returnValue); + return returnValue; + } + } diff --git a/projects/ngx-ramblers/src/app/services/page.service.ts b/projects/ngx-ramblers/src/app/services/page.service.ts index 1e3a453..7244116 100644 --- a/projects/ngx-ramblers/src/app/services/page.service.ts +++ b/projects/ngx-ramblers/src/app/services/page.service.ts @@ -90,7 +90,7 @@ export class PageService { return this.linksFromPathSegments(pathSegments, "View"); } - public linksFromPathSegments(pathSegments: string[], replaceMongoIdWith: string, includeLast?: boolean): Link[] { + public linksFromPathSegments(pathSegments: string[], replaceMongoIdWith?: string, includeLast?: boolean): Link[] { this.logger.info("pathSegments:", pathSegments); const relativePages: Link[] = pathSegments ?.filter(item => includeLast || item !== last(pathSegments)) diff --git a/projects/ngx-ramblers/src/app/services/walks/ramblers-walks-and-events.service.ts b/projects/ngx-ramblers/src/app/services/walks/ramblers-walks-and-events.service.ts index 4f203db..6b32928 100644 --- a/projects/ngx-ramblers/src/app/services/walks/ramblers-walks-and-events.service.ts +++ b/projects/ngx-ramblers/src/app/services/walks/ramblers-walks-and-events.service.ts @@ -10,6 +10,7 @@ import { Member } from "../../models/member.model"; import { RamblersUploadAuditApiResponse } from "../../models/ramblers-upload-audit.model"; import { ALL_EVENT_TYPES, + Contact, EventsListRequest, GroupListRequest, GroupWalk, @@ -22,7 +23,6 @@ import { RamblersWalksRawApiResponse, RamblersWalksRawApiResponseApiResponse, RamblersWalksUploadRequest, - WalkLeader, WALKS_MANAGER_API_DATE_FORMAT, WALKS_MANAGER_CSV_DATE_FORMAT, WALKS_MANAGER_GO_LIVE_DATE, @@ -31,6 +31,7 @@ import { } from "../../models/ramblers-walks-manager"; import { Ramblers } from "../../models/system.model"; import { + LocalContact, MongoIdsSupplied, Walk, WalkAscent, @@ -143,12 +144,12 @@ export class RamblersWalksAndEventsService { return this.commonDataService.responseFrom(this.logger, this.http.post(`${this.BASE_URL}/upload-walks`, data), this.auditSubject); } - async queryWalkLeaders(): Promise { - this.logger.debug("queryWalkLeaders:"); + async queryWalkLeaders(): Promise { + this.logger.info("queryWalkLeaders:"); const date = WALKS_MANAGER_GO_LIVE_DATE; const dateEnd = this.dateUtils.asMoment().add(12, "month").format(WALKS_MANAGER_API_DATE_FORMAT); const body: EventsListRequest = {types: [RamblersEventType.GROUP_WALK], date, dateEnd, limit: 2000}; - this.logger.off("queryWalkLeaders:body:", body); + this.logger.info("queryWalkLeaders:body:", body); const apiResponse = await this.commonDataService.responseFrom(this.logger, this.http.post(`${this.BASE_URL}/walk-leaders`, body), this.walkLeadersSubject); return apiResponse.response; } @@ -426,11 +427,11 @@ export class RamblersWalksAndEventsService { } if (isEmpty(walk.contactId)) { - validationMessages.push("Walk leader has no Ramblers Assemble Name entered on their member record. " + contactIdMessage); + validationMessages.push("Walk leader has no Walks Manager Contact Name entered on their member record. " + contactIdMessage); } if (!isNaN(+walk.contactId)) { - validationMessages.push(`Walk leader has an old Ramblers contact Id (${walk.contactId}) setup on their member record. This needs to be updated to an Assemble Full Name. ${contactIdMessage}`); + validationMessages.push(`Walk leader has an old Ramblers contact Id (${walk.contactId}) setup on their member record. This needs to be updated to an Walks Manager Contact Name. ${contactIdMessage}`); } if (isEmpty(walk.walkType)) { @@ -537,8 +538,8 @@ export class RamblersWalksAndEventsService { return this.walkToWalkUploadRow(walk); } - async all(dataQueryOptions?: DataQueryOptions): Promise { - return this.listRamblersWalksRawData(dataQueryOptions) + async all(dataQueryOptions?: DataQueryOptions, ids?: string[], types?: RamblersEventType[]): Promise { + return this.listRamblersWalksRawData(dataQueryOptions, ids, types) .then((ramblersWalksRawApiResponse: RamblersWalksRawApiResponse) => ramblersWalksRawApiResponse.data.map(remoteWalk => this.toWalk(remoteWalk))); } @@ -547,26 +548,30 @@ export class RamblersWalksAndEventsService { .then((ramblersWalksRawApiResponse: RamblersWalksRawApiResponse) => ramblersWalksRawApiResponse.data.map(remoteWalk => this.toSocialEvent(remoteWalk))); } - private generateContactData(groupWalk: GroupWalk) { - const startMoment = this.dateUtils.asMoment(groupWalk.start_date_time); - const contactName = groupWalk?.walk_leader?.name; + private localContact(groupWalk: GroupWalk): LocalContact { + const contact: Contact = groupWalk.walk_leader || groupWalk.event_organiser; + const telephone = contact?.telephone; + const id = contact?.id; + const email = contact?.email_form; + const contactName = contact?.name; const displayName = this.memberNamingService.createDisplayNameFromContactName(contactName); - return {startMoment, contactName, displayName}; + return {id, email, contactName, displayName, telephone}; } toWalk(groupWalk: GroupWalk): Walk { - const {startMoment, contactName, displayName} = this.generateContactData(groupWalk); + const startMoment = this.dateUtils.asMoment(groupWalk.start_date_time); + const contact: LocalContact = this.localContact(groupWalk); const walk: Walk = { eventType: groupWalk.item_type, media: groupWalk.media, ascent: groupWalk.ascent_feet?.toString(), briefDescriptionAndStartPoint: groupWalk.title, config: {meetup: null}, - contactEmail: groupWalk?.walk_leader?.email_form || groupWalk?.event_organiser?.email_form, - contactId: "n/a", - contactName, - contactPhone: groupWalk?.walk_leader?.telephone || groupWalk?.event_organiser?.telephone, - displayName, + contactEmail: contact?.email, + contactId: contact?.id, + contactName: contact?.contactName, + contactPhone: groupWalk?.walk_leader?.telephone, + displayName: contact?.displayName, distance: groupWalk?.distance_miles ? `${groupWalk?.distance_miles} miles` : "", events: [], grade: groupWalk.difficulty?.description, @@ -603,18 +608,19 @@ export class RamblersWalksAndEventsService { additionalDetails: groupWalk.additional_details, organiser:groupWalk?.event_organiser?.name }; - this.logger.info("groupWalk:", groupWalk, "walk:", walk, "contactName:", contactName, "displayName:", displayName); + this.logger.info("groupWalk:", groupWalk, "walk:", walk, "contactName:", contact.contactName, "displayName:", contact.displayName); return walk; } toSocialEvent(groupWalk: GroupWalk): SocialEvent { - const {startMoment, contactName, displayName} = this.generateContactData(groupWalk); + const startMoment = this.dateUtils.asMoment(groupWalk.start_date_time); + const contact: LocalContact = this.localContact(groupWalk); const socialEvent: SocialEvent = { id: groupWalk.id, - displayName, + displayName:contact.displayName, briefDescription: groupWalk.title, - contactEmail: groupWalk?.walk_leader?.email_form || groupWalk?.event_organiser?.email_form, - contactPhone: groupWalk?.walk_leader?.telephone || groupWalk?.event_organiser?.telephone, + contactEmail: contact?.email, + contactPhone: contact?.telephone, eventContactMemberId: null, eventDate: this.dateUtils.asValueNoTime(startMoment), eventTimeStart: this.dateUtils.asString(startMoment, undefined, this.dateUtils.formats.displayTime), @@ -632,7 +638,7 @@ export class RamblersWalksAndEventsService { thumbnail: this.mediaQueryService.imageUrlFrom(groupWalk), media: groupWalk.media, }; - this.logger.info("groupWalk:", groupWalk, "socialEvent:", socialEvent, "contactName:", contactName, "displayName:", displayName); + this.logger.info("groupWalk:", groupWalk, "socialEvent:", socialEvent, "contactName:", contact.contactName, "displayName:", contact.displayName); return socialEvent; } diff --git a/projects/ngx-ramblers/src/app/services/walks/walks-import.service.ts b/projects/ngx-ramblers/src/app/services/walks/walks-import.service.ts new file mode 100644 index 0000000..91dfe38 --- /dev/null +++ b/projects/ngx-ramblers/src/app/services/walks/walks-import.service.ts @@ -0,0 +1,203 @@ +import { Injectable } from "@angular/core"; +import { NgxLoggerLevel } from "ngx-logger"; +import { EventType, Walk } from "../../models/walk.model"; +import { Logger, LoggerFactory } from "../logger-factory.service"; +import { WalksLocalService } from "./walks-local.service"; +import { RamblersWalksAndEventsService } from "./ramblers-walks-and-events.service"; +import { Organisation, SystemConfig } from "../../models/system.model"; +import { SystemConfigService } from "../system/system-config.service"; +import first from "lodash-es/first"; +import last from "lodash-es/last"; +import { DateUtilsService } from "../date-utils.service"; +import omit from "lodash-es/omit"; +import { WalkEventService } from "./walk-event.service"; +import { MemberService } from "../member/member.service"; +import { NumberUtilsService } from "../number-utils.service"; +import { MemberBulkLoadService } from "../member/member-bulk-load.service"; +import { + BulkLoadMemberAndMatchToWalks, + Member, + MemberAction, + RamblersMemberAndContact, + WalksImportPreparation +} from "../../models/member.model"; +import { MemberNamingService } from "../member/member-naming.service"; +import { DataQueryOptions } from "../../models/api-request.model"; +import { StringUtilsService } from "../string-utils.service"; +import { Contact, RamblersEventType } from "../../models/ramblers-walks-manager"; +import { AlertInstance } from "../notifier.service"; + +@Injectable({ + providedIn: "root" +}) +export class WalksImportService { + + private readonly logger: Logger; + public group: Organisation; + private systemConfig: SystemConfig; + + constructor(private systemConfigService: SystemConfigService, + private walksLocalService: WalksLocalService, + private dateUtils: DateUtilsService, + private numberUtils: NumberUtilsService, + private walkEventService: WalkEventService, + private stringUtils: StringUtilsService, + private memberBulkLoadService: MemberBulkLoadService, + private memberService: MemberService, + private memberNamingService: MemberNamingService, + private ramblersWalksAndEventsService: RamblersWalksAndEventsService, + loggerFactory: LoggerFactory) { + this.logger = loggerFactory.createLogger("WalksImportService", NgxLoggerLevel.ERROR); + this.applyConfig(); + } + + private applyConfig() { + this.logger.info("applyConfig called"); + this.systemConfigService.events().subscribe(systemConfig => { + this.systemConfig = systemConfig; + this.logger.info("systemConfig:", this.systemConfig); + }); + } + + async prepareImport(messages: string[]): Promise { + // const searchString = "Dave M"; + const searchString = "Penny"; + const dataQueryOptions: DataQueryOptions = {criteria: {}, sort: {walkDate: 1}}; + const walksToImport = await this.ramblersWalksAndEventsService.all(dataQueryOptions, null, [RamblersEventType.GROUP_WALK]); + messages.push(`Found ${this.stringUtils.pluraliseWithCount(walksToImport.length, "walk")} to import`); + const walkLeaders = await this.ramblersWalksAndEventsService.queryWalkLeaders(); + messages.push(`Found ${this.stringUtils.pluraliseWithCount(walkLeaders.length, "walk leader")} to import`); + const firstWalk = first(walksToImport); + const lastWalk = last(walksToImport); + const walksWithContactId: Walk[] = walksToImport.filter(item => item.contactId); + const walksWithContactNameSearchString: Walk[] = walksToImport.filter(item => JSON.stringify(item).includes(searchString)); + this.logger.info("firstWalk:", firstWalk, "on", this.dateUtils.displayDate(firstWalk.walkDate), "lastWalk:", lastWalk, "on", this.dateUtils.displayDate(lastWalk.walkDate), "walksWithContactId:", walksWithContactId, "walksWithContactNameSearchString:", `${searchString}:`, walksWithContactNameSearchString); + messages.push(`First walk is on ${this.dateUtils.displayDate(firstWalk.walkDate)}`); + messages.push(`Last walk is on ${this.dateUtils.displayDate(lastWalk.walkDate)}`); + const existingWalks: Walk[] = await this.walksLocalService.all(); + const existingWalksWithinRange: Walk[] = existingWalks.filter(walk => walk.walkDate >= firstWalk.walkDate && walk.walkDate <= lastWalk.walkDate); + messages.push(`${this.stringUtils.pluraliseWithCount(existingWalksWithinRange.length, "existing walk")} within date range`); + this.logger.info("existingWalks:", existingWalks, "walks to import within range",); + this.logger.info("walkLeaders:", walkLeaders); + const members = await this.memberService.all(); + const ramblersMemberAndContacts: RamblersMemberAndContact[] = walkLeaders.map((walkLeader: Contact) => { + const firstAndLastName = this.memberNamingService.firstAndLastNameFrom(walkLeader.name); + return { + contact: walkLeader, + ramblersMember: { + mobileNumber: walkLeader.telephone, + firstName: firstAndLastName?.firstName, + lastName: firstAndLastName?.lastName, + email: null, + membershipNumber: null, + postcode: null + } + }; + }); + const unmatched: BulkLoadMemberAndMatchToWalks = { + bulkLoadMemberAndMatch: { + memberMatchType: "none", + member: null, + ramblersMember: null, + contact: null, + memberAction: MemberAction.notFound + }, walks: [] + }; + const bulkLoadMembersAndMatchesToWalks: BulkLoadMemberAndMatchToWalks[] = ramblersMemberAndContacts + .map(ramblersMemberAndContact => this.memberBulkLoadService.bulkLoadMemberAndMatchFor(ramblersMemberAndContact, members, this.systemConfig)) + .map(bulkLoadMemberAndMatch => ({bulkLoadMemberAndMatch, walks: []})).concat(unmatched); + + const unmatchedToMember: BulkLoadMemberAndMatchToWalks = bulkLoadMembersAndMatchesToWalks + .find(bulkLoadMemberAndMatch => bulkLoadMemberAndMatch === unmatched); + + walksToImport.forEach(walk => { + const matchToWalk: BulkLoadMemberAndMatchToWalks = bulkLoadMembersAndMatchesToWalks + .find(bulkLoadMemberAndMatch => { + if (bulkLoadMemberAndMatch.bulkLoadMemberAndMatch?.contact?.name) { + return bulkLoadMemberAndMatch.bulkLoadMemberAndMatch?.contact?.name === walk.contactName; + } else if (bulkLoadMemberAndMatch.bulkLoadMemberAndMatch?.member?.mobileNumber) { + return this.numberUtils.asNumber(bulkLoadMemberAndMatch.bulkLoadMemberAndMatch.member.mobileNumber) === this.numberUtils.asNumber(walk.contactPhone); + } else if (bulkLoadMemberAndMatch.bulkLoadMemberAndMatch?.contact?.id) { + return bulkLoadMemberAndMatch.bulkLoadMemberAndMatch.contact.id === walk.contactId; + } + }); + if (matchToWalk) { + matchToWalk.walks.push(walk); + } else { + if (unmatchedToMember) { + unmatchedToMember.walks.push(walk); + } + } + }); + bulkLoadMembersAndMatchesToWalks.forEach(bulkLoadMemberAndMatchToWalks => { + if (bulkLoadMemberAndMatchToWalks.walks.length === 0) { + bulkLoadMemberAndMatchToWalks.bulkLoadMemberAndMatch.memberAction = MemberAction.skipped; + } + }); + const bulkLoadMembersAndMatchesToWalksWithContactNameSearchString: BulkLoadMemberAndMatchToWalks[] = bulkLoadMembersAndMatchesToWalks.filter(item => JSON.stringify(item).includes(searchString)); + this.logger.info("bulkLoadMemberAndMatches:", bulkLoadMembersAndMatchesToWalks, "bulkLoadMembersAndMatchesToWalksWithContactNameSearchString:", `${searchString}:`, bulkLoadMembersAndMatchesToWalksWithContactNameSearchString); + messages.push(`${walksToImport.length - unmatchedToMember.walks.length} out of ${walksToImport?.length} were matched to a walk leader`); + return Promise.resolve({bulkLoadMembersAndMatchesToWalks, existingWalksWithinRange}); + } + + async performImport(walksImportPreparation: WalksImportPreparation, messages: string[], notify: AlertInstance): Promise { + const errorMessages: string[]=[]; + let createdWalks = 0; + let createdMembers = 0; + const deletions = await Promise.all(walksImportPreparation.existingWalksWithinRange.map(walk => this.walksLocalService.delete(walk))); + messages.push(`${deletions.length} existing walks deleted`); + const imports = await Promise.all(walksImportPreparation.bulkLoadMembersAndMatchesToWalks.map(async bulkLoadMemberAndMatchToWalks => { + const member = bulkLoadMemberAndMatchToWalks.bulkLoadMemberAndMatch.member; + if (bulkLoadMemberAndMatchToWalks.bulkLoadMemberAndMatch.memberAction === MemberAction.found) { + return bulkLoadMemberAndMatchToWalks.walks.map(walk => { + createdWalks++; + return this.applyWalkLeaderIfSuppliedAndSaveWalk(walk, member); + }); + } else if (bulkLoadMemberAndMatchToWalks.bulkLoadMemberAndMatch.memberAction === MemberAction.created) { + if (bulkLoadMemberAndMatchToWalks.walks.length > 0) { + const qualifier = `for ${member.firstName} ${member.lastName}`; + const createdMember: Member = await this.memberService.createOrUpdate(member) + .then((savedMember: Member) => { + notify.success({title: "Walks Import", message: `Member creation ${qualifier} was successful`}); + return savedMember; + }).catch(response => { + this.logger.error("member save error for member:", member, "response:", response); + bulkLoadMemberAndMatchToWalks.bulkLoadMemberAndMatch.memberAction = MemberAction.error; + const message = `Member creation ${qualifier} failed`; + errorMessages.push(message); + messages.push(message); + notify.warning({title: "Walks Import", message}); + return null; + }); + createdMembers++; + return bulkLoadMemberAndMatchToWalks.walks.map(walk => { + createdWalks++; + return this.applyWalkLeaderIfSuppliedAndSaveWalk(walk, createdMember); + }); + } else { + this.logger.info("member:", member, "was not matched to any walks"); + return Promise.resolve(); + } + } else { + this.logger.info("processing memberAction:", bulkLoadMemberAndMatchToWalks.bulkLoadMemberAndMatch.memberAction, "with", this.stringUtils.pluraliseWithCount(bulkLoadMemberAndMatchToWalks.walks.length, "matched walk")); + return bulkLoadMemberAndMatchToWalks.walks.map(walk => { + createdWalks++; + return this.applyWalkLeaderIfSuppliedAndSaveWalk(walk); + }); + } + })); + this.logger.info("imports:", imports); + messages.push(`${this.stringUtils.pluraliseWithCount(createdMembers, "new member")} created, ${this.stringUtils.pluraliseWithCount(createdWalks, "walk")} imported`); + return errorMessages; + } + + private applyWalkLeaderIfSuppliedAndSaveWalk(walk: Walk, member?: Member): Promise { + const unsavedWalk: Walk = omit(walk, ["_id", "id"]) as Walk; + if (member) { + unsavedWalk.walkLeaderMemberId = member.id; + } + const event = this.walkEventService.createEventIfRequired(unsavedWalk, EventType.APPROVED, "Imported from Walks Manager"); + this.walkEventService.writeEventIfRequired(unsavedWalk, event); + return this.walksLocalService.createOrUpdate(unsavedWalk); + } +} diff --git a/projects/ngx-ramblers/src/app/services/walks/walks.service.ts b/projects/ngx-ramblers/src/app/services/walks/walks.service.ts index 6029a96..90a04b5 100644 --- a/projects/ngx-ramblers/src/app/services/walks/walks.service.ts +++ b/projects/ngx-ramblers/src/app/services/walks/walks.service.ts @@ -2,17 +2,12 @@ import { Injectable } from "@angular/core"; import { NgxLoggerLevel } from "ngx-logger"; import { Observable } from "rxjs"; import { DataQueryOptions } from "../../models/api-request.model"; -import { EventType, Walk, WalkApiResponse } from "../../models/walk.model"; +import { Walk, WalkApiResponse } from "../../models/walk.model"; import { Logger, LoggerFactory } from "../logger-factory.service"; import { WalksLocalService } from "./walks-local.service"; import { RamblersWalksAndEventsService } from "./ramblers-walks-and-events.service"; import { EventPopulation, Organisation } from "../../models/system.model"; import { SystemConfigService } from "../system/system-config.service"; -import first from "lodash-es/first"; -import last from "lodash-es/last"; -import { DateUtilsService } from "../date-utils.service"; -import omit from "lodash-es/omit"; -import { WalkEventService } from "./walk-event.service"; @Injectable({ providedIn: "root" @@ -24,11 +19,9 @@ export class WalksService { constructor(private systemConfigService: SystemConfigService, private walksLocalService: WalksLocalService, - private dateUtils: DateUtilsService, - private walkEventService: WalkEventService, private ramblersWalksAndEventsService: RamblersWalksAndEventsService, loggerFactory: LoggerFactory) { - this.logger = loggerFactory.createLogger(WalksService, NgxLoggerLevel.OFF); + this.logger = loggerFactory.createLogger(WalksService, NgxLoggerLevel.ERROR); this.applyConfig(); } @@ -45,7 +38,7 @@ export class WalksService { } async all(dataQueryOptions?: DataQueryOptions): Promise { - this.logger.info("all called with walkPopulation:", this.group?.walkPopulation); + this.logger.info("all called with walkPopulation:", this.group?.walkPopulation, "dataQueryOptions:", dataQueryOptions); switch (this.group?.walkPopulation) { case EventPopulation.WALKS_MANAGER: return this.ramblersWalksAndEventsService.all(dataQueryOptions); @@ -93,22 +86,4 @@ export class WalksService { return this.walksLocalService.fixIncorrectWalkDates(); } - async copyWalks(walks: Walk[]) { - const firstWalk = first(walks); - const lastWalk = last(walks); - this.logger.info("firstWalk:", firstWalk, "on", this.dateUtils.displayDate(firstWalk.walkDate), "lastWalk:", lastWalk, "on", this.dateUtils.displayDate(lastWalk.walkDate)); - const existingWalks = await this.walksLocalService.all(); - const walksWithinRange = existingWalks.filter(walk => walk.walkDate >= firstWalk.walkDate && walk.walkDate <= lastWalk.walkDate); - this.logger.info("existingWalks:", existingWalks, "walks within range", walksWithinRange); - walksWithinRange.forEach(walk => this.walksLocalService.delete(walk)); - Promise.all(walks.map(walk => { - const walkWithoutId: Walk = omit(walk, ["_id", "id"]) as Walk; - this.logger.info("copying walk:", walkWithoutId); - const event = this.walkEventService.createEventIfRequired(walkWithoutId, EventType.APPROVED, "Imported from Walks Manager"); - this.walkEventService.writeEventIfRequired(walkWithoutId, event); - return this.walksLocalService.createOrUpdate(walkWithoutId); - })).then(response => { - this.logger.info("walk copy completed with response:", response); - }); - } } diff --git a/server/lib/mongo/models/walk.ts b/server/lib/mongo/models/walk.ts index 90aad2d..2f4b16c 100644 --- a/server/lib/mongo/models/walk.ts +++ b/server/lib/mongo/models/walk.ts @@ -9,6 +9,11 @@ const riskAssessmentRecord = { riskAssessmentKey: {type: String}, }; +const metaData = { + code: {type: String}, + description: {type: String}, +}; + const walkEvent = { data: {type: Object}, eventType: {type: String}, @@ -42,6 +47,7 @@ const media = { alt: {type: String}, styles: [mediaStyle] }; + const walkSchema = new mongoose.Schema({ contactName: {type: String}, walkType: {type: String}, @@ -86,7 +92,9 @@ const walkSchema = new mongoose.Schema({ walkLeaderMemberId: {type: String}, venue: walkVenue, riskAssessment: [riskAssessmentRecord], - media: [media] + media: [media], + features: [metaData], + startLocation: {type: String}, }, {collection: "walks"}); walkSchema.plugin(uniqueValidator); diff --git a/server/lib/ramblers/list-events.ts b/server/lib/ramblers/list-events.ts index ba04033..e8631dd 100644 --- a/server/lib/ramblers/list-events.ts +++ b/server/lib/ramblers/list-events.ts @@ -3,12 +3,12 @@ import first from "lodash/first"; import isEmpty from "lodash/isEmpty"; import moment from "moment-timezone"; import { + Contact, + EventsListRequest, GroupWalk, RamblersWalkResponse, RamblersWalksRawApiResponse, RamblersWalksRawApiResponseApiResponse, - WalkLeader, - EventsListRequest, WALKS_MANAGER_API_DATE_FORMAT, WALKS_MANAGER_GO_LIVE_DATE } from "../../../projects/ngx-ramblers/src/app/models/ramblers-walks-manager"; @@ -18,17 +18,39 @@ import { httpRequest, optionalParameter } from "../shared/message-handlers"; import * as requestDefaults from "./request-defaults"; import groupBy from "lodash/groupBy"; import map from "lodash/map"; -import omit from "lodash/omit"; import { systemConfig } from "../config/system-config"; import { Request, Response } from "express"; import { WalkLeadersApiResponse } from "../../../projects/ngx-ramblers/src/app/models/walk.model"; import { pluraliseWithCount } from "../../serenity-js/screenplay/util/util"; +import omit from "lodash/omit"; const debugLog = debug(envConfig.logNamespace("ramblers:walks-and-events")); const noopDebugLog = debug(envConfig.logNamespace("ramblers:walks-and-events")); noopDebugLog.enabled = false; debugLog.enabled = false; + +function identity(walkLeader: Contact) { + return walkLeader.id || walkLeader.telephone || walkLeader.name; +} + +function toWalkLeaders(response: RamblersWalksRawApiResponse): Contact[] { + let unNamedIndex = 0; + noopDebugLog("transformListWalksResponse:", response); + const filteredWalkLeaders: Contact[] = response.data.map((walk: GroupWalk) => omit(walk.walk_leader, ["email_form"])).filter(item => !isEmpty(identity(item))); + const groupedWalkLeaders = groupBy(filteredWalkLeaders, (walkLeader => identity(walkLeader))); + noopDebugLog("groupedWalkLeaders:", groupedWalkLeaders); + return map(groupedWalkLeaders, (items, key, index) => { + const result: Contact = first(items.sort((a, b) => b.name.length - a.name.length)); + if (isEmpty(result.name)) { + unNamedIndex++; + result.name = `Unknown Leader ${unNamedIndex}`; + } + noopDebugLog("result:", result, "from items:", items, "key:", key); + return result; + }); +} + export function walkLeaders(req: Request, res: Response): void { const body: EventsListRequest = req.body; debugLog("listEvents:body:", body); @@ -59,13 +81,7 @@ export function walkLeaders(req: Request, res: Response): void { debug: noopDebugLog, res, req, - mapper: (response: RamblersWalksRawApiResponse): WalkLeader[] => { - debugLog("transformListWalksResponse:", response); - const filteredWalkLeaders: WalkLeader[] = response.data.map((walk: GroupWalk) => omit(walk.walk_leader, ["email_form"])).filter(item => !isEmpty(item.name)); - const groupedWalkLeaders = groupBy(filteredWalkLeaders, (walkLeader => walkLeader.id || walkLeader.name)); - debugLog("groupedWalkLeaders:", groupedWalkLeaders); - return map(groupedWalkLeaders, (items, key) => first(items)); - } + mapper: toWalkLeaders }); }) .then((response: WalkLeadersApiResponse) => {