From 93032743492784f1fb324775dd79dfe560aa5d4a Mon Sep 17 00:00:00 2001 From: Devin Major Date: Tue, 28 Apr 2026 15:44:46 -0400 Subject: [PATCH] all Data Export API changes from April 28 2026 --- Development/client/angular.json | 5 + Development/client/docs/CHANGELOG.md | 8 + Development/client/package-lock.json | 108 ++++++- Development/client/package.json | 4 +- .../client/src/app/app-routing.module.ts | 10 + .../client/src/app/app.menu.component.ts | 4 +- .../app/changelog/changelog-routing.module.ts | 18 ++ .../app/changelog/changelog.component.html | 20 ++ .../src/app/changelog/changelog.component.ts | 62 ++++ .../src/app/changelog/changelog.module.ts | 20 ++ .../customer-edit.component.html | 14 + .../customer-edit/customer-edit.component.ts | 41 ++- .../app/customers/models/customer.model.ts | 2 + .../domain/services/browser-cache.service.ts | 9 +- .../api-key-manager.component.css | 10 +- .../api-keys/api-keys-routing.module.ts | 2 +- .../signup-form/signup-form.component.html | 17 +- .../signup-form/signup-form.component.ts | 18 ++ .../src/app/tools/dealers/dealer.service.ts | 43 +++ .../tools/dealers/dealers-routing.module.ts | 23 ++ .../app/tools/dealers/dealers.component.css | 12 + .../app/tools/dealers/dealers.component.html | 176 ++++++++++ .../app/tools/dealers/dealers.component.ts | 131 ++++++++ .../src/app/tools/dealers/dealers.module.ts | 23 ++ Development/server/controllers/customer.js | 3 +- Development/server/controllers/dealer.js | 46 +++ Development/server/controllers/user.js | 3 + .../server/middlewares/app_validator.js | 1 + Development/server/model/dealer.js | 32 ++ Development/server/model/user.js | 3 + Development/server/routes/dealer.js | 14 + Development/server/routes/index.js | 1 + Development/server/routes/user.js | 1 + Development/server/scripts/seedDealers.js | 303 ++++++++++++++++++ 34 files changed, 1166 insertions(+), 21 deletions(-) create mode 100644 Development/client/docs/CHANGELOG.md create mode 100644 Development/client/src/app/changelog/changelog-routing.module.ts create mode 100644 Development/client/src/app/changelog/changelog.component.html create mode 100644 Development/client/src/app/changelog/changelog.component.ts create mode 100644 Development/client/src/app/changelog/changelog.module.ts create mode 100644 Development/client/src/app/tools/dealers/dealer.service.ts create mode 100644 Development/client/src/app/tools/dealers/dealers-routing.module.ts create mode 100644 Development/client/src/app/tools/dealers/dealers.component.css create mode 100644 Development/client/src/app/tools/dealers/dealers.component.html create mode 100644 Development/client/src/app/tools/dealers/dealers.component.ts create mode 100644 Development/client/src/app/tools/dealers/dealers.module.ts create mode 100644 Development/server/controllers/dealer.js create mode 100644 Development/server/model/dealer.js create mode 100644 Development/server/routes/dealer.js create mode 100644 Development/server/scripts/seedDealers.js diff --git a/Development/client/angular.json b/Development/client/angular.json index c0831f7..7bb5c52 100644 --- a/Development/client/angular.json +++ b/Development/client/angular.json @@ -52,6 +52,11 @@ "glob": "**/*", "input": "node_modules/leaflet/dist/images", "output": "/assets/images" + }, + { + "glob": "CHANGELOG.md", + "input": "docs", + "output": "/assets/docs" } ], "styles": [ diff --git a/Development/client/docs/CHANGELOG.md b/Development/client/docs/CHANGELOG.md new file mode 100644 index 0000000..8f7560a --- /dev/null +++ b/Development/client/docs/CHANGELOG.md @@ -0,0 +1,8 @@ +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [revision 1603] - 2026-04-28 + +- Created the changelog file \ No newline at end of file diff --git a/Development/client/package-lock.json b/Development/client/package-lock.json index 005dad2..99c8272 100644 --- a/Development/client/package-lock.json +++ b/Development/client/package-lock.json @@ -40,8 +40,10 @@ "geodesy": "^1.1.3", "intl": "^1.2.5", "leaflet": "^1.9.4", + "marked": "^1.2.9", "ngrx-store-localstorage": "^9.0.0", "ngx-captcha": "^8.0.1", + "ngx-markdown": "^9.1.1", "primeng-lts": "^9.2.8", "quill": "^1.3.7", "rbush": "^3.0.1", @@ -2838,6 +2840,11 @@ "@types/leaflet": "*" } }, + "node_modules/@types/marked": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.7.4.tgz", + "integrity": "sha512-fdg0NO4qpuHWtZk6dASgsrBggY+8N4dWthl1bAQG9ceKUNKFjqpHaDKCAhRUI6y8vavG7hLSJ4YBwJtZyZEXqw==" + }, "node_modules/@types/minimatch": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-6.0.0.tgz", @@ -5032,8 +5039,7 @@ "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/commondir": { "version": "1.0.1", @@ -6455,6 +6461,11 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/emoji-toolkit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-5.5.1.tgz", + "integrity": "sha512-H8E6DNTsRLgy1FVWAiyuW4nqHka0rvUkXhmJPzL28gXo4pLKvuoEi6VhodJ1RfIZOZZ7Zmxo1sENYinyytl/ww==" + }, "node_modules/emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", @@ -10165,6 +10176,17 @@ "node": ">=0.10.0" } }, + "node_modules/katex": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.11.1.tgz", + "integrity": "sha512-5oANDICCTX0NqYIyAiFCCwjQ7ERu3DQG2JFHLbYOf+fXaMoH8eg/zOq5WSYJsKMi/QebW+Eh3gSM+oss1H/bww==", + "dependencies": { + "commander": "^2.19.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -10660,6 +10682,17 @@ "node": ">=0.10.0" } }, + "node_modules/marked": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/marked/-/marked-1.2.9.tgz", + "integrity": "sha512-H8lIX2SvyitGX+TRdtS06m1jHMijKN/XjfH6Ooii9fvxMlh8QdqBfBDkGUpMWH2kQNrtixjzYUa3SH8ROTgRRw==", + "bin": { + "marked": "bin/marked" + }, + "engines": { + "node": ">= 8.16.2" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -11268,6 +11301,26 @@ "node": ">=4" } }, + "node_modules/ngx-markdown": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-9.1.1.tgz", + "integrity": "sha512-dEuR1KBa/Ivb1HT+DvSW1p6wLSx79EZz8/WpgDxiEZfL1PADTQUziNLgmtwAEgBTDjsXFhZ/Af7Zq5J9dQf6KQ==", + "dependencies": { + "@types/marked": "^0.7.4", + "emoji-toolkit": "^5.5.0", + "katex": "^0.11.0", + "marked": "^1.1.0", + "prismjs": "^1.20.0" + }, + "peerDependencies": { + "@angular/common": "^8.0.0 || ^9.0.0", + "@angular/core": "^8.0.0 || ^9.0.0", + "@angular/platform-browser": "^8.0.0 || ^9.0.0", + "rxjs": "^6.0.0", + "tslib": "^1.10.0", + "zone.js": "^0.9.0 || ^0.10.0" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -13301,6 +13354,14 @@ "zone.js": "^0.10.2" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "engines": { + "node": ">=6" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -21768,6 +21829,11 @@ "@types/leaflet": "*" } }, + "@types/marked": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.7.4.tgz", + "integrity": "sha512-fdg0NO4qpuHWtZk6dASgsrBggY+8N4dWthl1bAQG9ceKUNKFjqpHaDKCAhRUI6y8vavG7hLSJ4YBwJtZyZEXqw==" + }, "@types/minimatch": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-6.0.0.tgz", @@ -23550,8 +23616,7 @@ "commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "commondir": { "version": "1.0.1", @@ -24713,6 +24778,11 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "emoji-toolkit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-5.5.1.tgz", + "integrity": "sha512-H8E6DNTsRLgy1FVWAiyuW4nqHka0rvUkXhmJPzL28gXo4pLKvuoEi6VhodJ1RfIZOZZ7Zmxo1sENYinyytl/ww==" + }, "emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", @@ -27599,6 +27669,14 @@ "source-map-support": "^0.5.5" } }, + "katex": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.11.1.tgz", + "integrity": "sha512-5oANDICCTX0NqYIyAiFCCwjQ7ERu3DQG2JFHLbYOf+fXaMoH8eg/zOq5WSYJsKMi/QebW+Eh3gSM+oss1H/bww==", + "requires": { + "commander": "^2.19.0" + } + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -28002,6 +28080,11 @@ "object-visit": "^1.0.0" } }, + "marked": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/marked/-/marked-1.2.9.tgz", + "integrity": "sha512-H8lIX2SvyitGX+TRdtS06m1jHMijKN/XjfH6Ooii9fvxMlh8QdqBfBDkGUpMWH2kQNrtixjzYUa3SH8ROTgRRw==" + }, "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -28492,6 +28575,18 @@ "xmldom": "^0.1.27" } }, + "ngx-markdown": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-9.1.1.tgz", + "integrity": "sha512-dEuR1KBa/Ivb1HT+DvSW1p6wLSx79EZz8/WpgDxiEZfL1PADTQUziNLgmtwAEgBTDjsXFhZ/Af7Zq5J9dQf6KQ==", + "requires": { + "@types/marked": "^0.7.4", + "emoji-toolkit": "^5.5.0", + "katex": "^0.11.0", + "marked": "^1.1.0", + "prismjs": "^1.20.0" + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -30193,6 +30288,11 @@ "integrity": "sha512-0cxWuVEuruMFT5GovcnNSqyzn+f/qgCUdEXHEcJ297ySp5P1S5HB8IfsmHkCEpI8BDU+X6k3seP9FtzWeqxigw==", "requires": {} }, + "prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==" + }, "process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", diff --git a/Development/client/package.json b/Development/client/package.json index fc87927..a596a77 100644 --- a/Development/client/package.json +++ b/Development/client/package.json @@ -58,8 +58,10 @@ "geodesy": "^1.1.3", "intl": "^1.2.5", "leaflet": "^1.9.4", + "marked": "^1.2.9", "ngrx-store-localstorage": "^9.0.0", "ngx-captcha": "^8.0.1", + "ngx-markdown": "^9.1.1", "primeng-lts": "^9.2.8", "quill": "^1.3.7", "rbush": "^3.0.1", @@ -110,4 +112,4 @@ }, "websocket-driver": "0.7.3" } -} \ No newline at end of file +} diff --git a/Development/client/src/app/app-routing.module.ts b/Development/client/src/app/app-routing.module.ts index 97fcb68..2805e1b 100644 --- a/Development/client/src/app/app-routing.module.ts +++ b/Development/client/src/app/app-routing.module.ts @@ -74,6 +74,11 @@ const routes: Routes = [ loadChildren: () => import('./tools/dlq-monitor/dlq-monitor.module').then(m => m.DlqMonitorModule), runGuardsAndResolvers: 'always', }, + { + path: 'dealers', + loadChildren: () => import('./tools/dealers/dealers.module').then(m => m.DealersModule), + runGuardsAndResolvers: 'always', + }, { path: 'track', loadChildren: () => import('./track/track.module').then(m => m.TrackModule), @@ -100,6 +105,11 @@ const routes: Routes = [ loadChildren: () => import('./settings/api-keys/api-keys.module').then(m => m.ApiKeysModule), runGuardsAndResolvers: 'always' }, + { + path: 'changelog', + loadChildren: () => import('./changelog/changelog.module').then(m => m.ChangelogModule), + runGuardsAndResolvers: 'always' + }, ], }, { diff --git a/Development/client/src/app/app.menu.component.ts b/Development/client/src/app/app.menu.component.ts index b7cb50a..4b4eba8 100644 --- a/Development/client/src/app/app.menu.component.ts +++ b/Development/client/src/app/app.menu.component.ts @@ -39,6 +39,7 @@ export class AppMenuComponent implements OnInit { const mItems: MenuItem[] = [ { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] }, { id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] }, + { id: 'dealers', label: $localize`:@@dealers:Dealers`, icon: 'store', routerLink: ['/dealers'] }, { id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] }, { label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] }, { @@ -58,6 +59,7 @@ export class AppMenuComponent implements OnInit { { id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] } ] }, + { id: 'changelog', label: $localize`:@@changelog:Changelog`, icon: 'history', routerLink: ['/changelog'] }, ]; this.model = mItems; } @@ -219,7 +221,7 @@ export class AppMenuComponent implements OnInit { { id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] }, { id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] }, { id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }, - ...( this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM]) + ...( this.authSvc.hasRole([RoleIds.APP]) ? [{ id: 'api-keys', label: $localize`:@@apiKeys:API Keys`, icon: 'vpn_key', routerLink: ['/api-keys'] }] : [] ) ] diff --git a/Development/client/src/app/changelog/changelog-routing.module.ts b/Development/client/src/app/changelog/changelog-routing.module.ts new file mode 100644 index 0000000..ed61857 --- /dev/null +++ b/Development/client/src/app/changelog/changelog-routing.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { AuthGuard } from '../domain/guards/auth.guard'; +import { ChangelogComponent } from './changelog.component'; + +const routes: Routes = [ + { + path: '', + component: ChangelogComponent, + canActivate: [AuthGuard] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class ChangelogRoutingModule { } diff --git a/Development/client/src/app/changelog/changelog.component.html b/Development/client/src/app/changelog/changelog.component.html new file mode 100644 index 0000000..7222926 --- /dev/null +++ b/Development/client/src/app/changelog/changelog.component.html @@ -0,0 +1,20 @@ +
+
+
+

Changelog

+
+ +
+ + +
+
+
+
+
+
diff --git a/Development/client/src/app/changelog/changelog.component.ts b/Development/client/src/app/changelog/changelog.component.ts new file mode 100644 index 0000000..d9b027b --- /dev/null +++ b/Development/client/src/app/changelog/changelog.component.ts @@ -0,0 +1,62 @@ +import { Component, OnInit } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import * as marked from 'marked'; +const parseMarkdown: (src: string) => string = (marked as any).marked ?? (marked as any).default ?? (marked as any); + +interface ChangelogSection { + header: string; + content: SafeHtml; +} + +@Component({ + selector: 'app-changelog', + templateUrl: './changelog.component.html' +}) +export class ChangelogComponent implements OnInit { + sections: ChangelogSection[] = []; + loading = true; + + constructor( + private readonly http: HttpClient, + private readonly sanitizer: DomSanitizer + ) { } + + ngOnInit(): void { + this.http.get('/assets/docs/CHANGELOG.md', { responseType: 'text' }).subscribe({ + next: (md) => { + this.sections = this.parseSections(md); + this.loading = false; + }, + error: () => { this.loading = false; } + }); + } + + private parseSections(md: string): ChangelogSection[] { + const sections: ChangelogSection[] = []; + let currentHeader = ''; + let currentBody = ''; + + for (const line of md.split('\n')) { + if (line.startsWith('## ')) { + if (currentHeader) { + sections.push({ + header: currentHeader, + content: this.sanitizer.bypassSecurityTrustHtml(parseMarkdown(currentBody.trim())) + }); + } + currentHeader = line.replace(/^##\s*/, ''); + currentBody = ''; + } else if (currentHeader) { + currentBody += line + '\n'; + } + } + if (currentHeader) { + sections.push({ + header: currentHeader, + content: this.sanitizer.bypassSecurityTrustHtml(parseMarkdown(currentBody.trim())) + }); + } + return sections; + } +} diff --git a/Development/client/src/app/changelog/changelog.module.ts b/Development/client/src/app/changelog/changelog.module.ts new file mode 100644 index 0000000..ab7920a --- /dev/null +++ b/Development/client/src/app/changelog/changelog.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { HttpClientModule } from '@angular/common/http'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { PanelModule } from 'primeng/panel'; + +import { AppSharedModule } from '../shared/app-shared.module'; +import { ChangelogRoutingModule } from './changelog-routing.module'; +import { ChangelogComponent } from './changelog.component'; + +@NgModule({ + imports: [ + AppSharedModule, + HttpClientModule, + ProgressSpinnerModule, + PanelModule, + ChangelogRoutingModule + ], + declarations: [ChangelogComponent] +}) +export class ChangelogModule { } diff --git a/Development/client/src/app/customers/customer-edit/customer-edit.component.html b/Development/client/src/app/customers/customer-edit/customer-edit.component.html index b1f31a0..7122b78 100644 --- a/Development/client/src/app/customers/customer-edit/customer-edit.component.html +++ b/Development/client/src/app/customers/customer-edit/customer-edit.component.html @@ -56,6 +56,20 @@ + +
+ Dealer: + + +
+
diff --git a/Development/client/src/app/customers/customer-edit/customer-edit.component.ts b/Development/client/src/app/customers/customer-edit/customer-edit.component.ts index 5eaba87..303743e 100644 --- a/Development/client/src/app/customers/customer-edit/customer-edit.component.ts +++ b/Development/client/src/app/customers/customer-edit/customer-edit.component.ts @@ -6,6 +6,7 @@ import { Customer, Partner } from '../models/customer.model'; import * as customerActions from '../actions/customer.actions'; import { UserService } from '@app/domain/services/user.service'; import { PartnerService } from '@app/partners/services/partner.service'; +import { Dealer, DealerService } from '@app/tools/dealers/dealer.service'; import { BaseComp } from '@app/shared/base/base.component'; import { GC, RoleIds, globals, Labels } from '@app/shared/global'; import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model'; @@ -40,6 +41,10 @@ export class CustomerEditComponent extends BaseComp implements OnInit { partnerLoading = false; partnerError: string | null = null; + // Dealer Selection Properties + dealerOptions: SelectItem[] = []; + dealerLoading = false; + private _customer: Customer; get customer(): Customer { return this._customer; } set customer(customer: Customer) { @@ -51,7 +56,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit { premium: this.selectedItem.premium, billable: this.selectedItem.billable, trials: this.selectedItem.membership?.trials, - partner: this.selectedItem.partner || null + partner: this.selectedItem.partner || null, + dealer: this.selectedItem.dealer || null }); // Set partner selection based on customer.partner field, or null if not set @@ -67,6 +73,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit { private readonly route: ActivatedRoute, private readonly userSvc: UserService, private readonly partnerSvc: PartnerService, + private readonly dealerSvc: DealerService, private readonly fb: FormBuilder ) { super(); @@ -83,7 +90,9 @@ export class CustomerEditComponent extends BaseComp implements OnInit { billable: [], trials: [], // Partner form control - partner: [null] + partner: [null], + // Dealer form control + dealer: [null] }); this.lang = this.authSvc.locale; @@ -106,6 +115,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit { } // Load partners from service this.loadPartners(); + this.loadDealers(); } }); @@ -171,7 +181,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit { custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account, { premium: this.form.value.premium || false }, { billable: this.form.value.billable || false }, - { partner: this.form.value.partner || null }); + { partner: this.form.value.partner || null }, + { dealer: this.form.value.dealer?._id || this.form.value.dealer || null }); this.membership ? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) }) @@ -209,6 +220,30 @@ export class CustomerEditComponent extends BaseComp implements OnInit { return DateUtils.dateToTS(date); } + // Dealer Methods + private loadDealers(): void { + this.dealerLoading = true; + this.dealerSvc.getAll().subscribe({ + next: (dealers: Dealer[]) => { + this.dealerOptions = [ + { label: 'None', value: null }, + ...dealers + .sort((a, b) => a.companyName.localeCompare(b.companyName)) + .map(d => ({ + label: d.country ? `${d.companyName} (${d.country})` : d.companyName, + value: d + })) + ]; + if (this.customer?.dealer) { + const match = this.dealerOptions.find(o => o.value && o.value._id === (this.customer.dealer as any)?._id); + if (match) { this.form.patchValue({ dealer: match.value }); } + } + this.dealerLoading = false; + }, + error: () => { this.dealerLoading = false; } + }); + } + // Partner Methods private loadPartners(): void { this.partnerLoading = true; diff --git a/Development/client/src/app/customers/models/customer.model.ts b/Development/client/src/app/customers/models/customer.model.ts index d855f91..d49c4e6 100644 --- a/Development/client/src/app/customers/models/customer.model.ts +++ b/Development/client/src/app/customers/models/customer.model.ts @@ -1,6 +1,7 @@ import { RoleIds } from '@app/shared/global'; import { createNewUser, User } from '@app/accounts/models/user.model'; import { IMembership } from '@app/auth/models/user.model'; +import { Dealer } from '@app/tools/dealers/dealer.service'; export interface Customer extends User { contact?: string; @@ -10,6 +11,7 @@ export interface Customer extends User { totalJobs?: number; membership: IMembership, partner?: Partner; + dealer?: Dealer; selfSignup?: boolean; } diff --git a/Development/client/src/app/domain/services/browser-cache.service.ts b/Development/client/src/app/domain/services/browser-cache.service.ts index 8bf080c..7006dc4 100644 --- a/Development/client/src/app/domain/services/browser-cache.service.ts +++ b/Development/client/src/app/domain/services/browser-cache.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { from, Observable, of } from 'rxjs'; import { catchError, switchMap } from 'rxjs/operators'; import { AppConfigService } from './app-config.service'; +import { AuthService } from './auth.service'; /** Shape of every entry stored in Cache Storage. */ export interface BrowserCacheEntry { @@ -37,10 +38,14 @@ export class BrowserCacheService { private readonly supported = typeof caches !== 'undefined'; private readonly fallbackMaxAgeMs = 60_000; - constructor(private readonly appConfig: AppConfigService) {} + constructor( + private readonly appConfig: AppConfigService, + private readonly authSvc: AuthService + ) {} private ttlStorageKey(cacheName: string): string { - return `browser-cache-ttl:${cacheName}`; + const userId = this.authSvc.user?._id || 'anonymous'; + return `browser-cache-ttl:${userId}:${cacheName}`; } private get defaultMaxAgeMs(): number { diff --git a/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.css b/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.css index 773e59b..6905f70 100644 --- a/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.css +++ b/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.css @@ -53,13 +53,15 @@ } .badge-active { - background: #A5D6A7; - color: #2E7D32; + background: #4CAF50; + border: 1px solid #2E7D32; + color: #fff; } .badge-revoked { - background: #ffcdd2; - color: #b71c1c; + background: #f44336; + border: 1px solid #d32f2f; + color: #fff; } .empty-message { diff --git a/Development/client/src/app/settings/api-keys/api-keys-routing.module.ts b/Development/client/src/app/settings/api-keys/api-keys-routing.module.ts index f874c5a..49b7982 100644 --- a/Development/client/src/app/settings/api-keys/api-keys-routing.module.ts +++ b/Development/client/src/app/settings/api-keys/api-keys-routing.module.ts @@ -10,7 +10,7 @@ const routes: Routes = [ path: '', component: ApiKeyManagerComponent, data: { - roles: [RoleIds.ADMIN, RoleIds.APP, RoleIds.APP_ADM] + roles: [RoleIds.ADMIN, RoleIds.APP] }, canActivate: [AuthGuard] } diff --git a/Development/client/src/app/signup/signup-form/signup-form.component.html b/Development/client/src/app/signup/signup-form/signup-form.component.html index 83e8a09..0f88755 100644 --- a/Development/client/src/app/signup/signup-form/signup-form.component.html +++ b/Development/client/src/app/signup/signup-form/signup-form.component.html @@ -224,10 +224,19 @@
-
Partners
-
Select a partner if you are using AgMission with our partner's systems.
-
- +
+
Partners
+
Select a partner if you are using AgMission with our partner's systems.
+
+ +
+
+
+
Dealer
+
Select the dealer who sold or supports your AG-NAV system.
+
+ +
diff --git a/Development/client/src/app/signup/signup-form/signup-form.component.ts b/Development/client/src/app/signup/signup-form/signup-form.component.ts index 191d4cc..c94b874 100644 --- a/Development/client/src/app/signup/signup-form/signup-form.component.ts +++ b/Development/client/src/app/signup/signup-form/signup-form.component.ts @@ -15,6 +15,7 @@ import { catchError, switchMap, tap } from 'rxjs/operators'; import { UniqueUserValidator } from '@app/shared/user-unique.directive'; import { CommonService } from '@app/domain/services/common.service'; import { PartnerService } from '@app/partners/services/partner.service'; +import { DealerService } from '@app/tools/dealers/dealer.service'; import { of } from 'rxjs'; import { ActivatedRoute } from '@angular/router'; import { GAService } from '@app/shared/ga.service'; @@ -58,6 +59,7 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy, readonly billing = 'billing'; readonly password = 'password'; readonly partner = 'partner'; + readonly dealer = 'dealer'; @ViewChild("captchaElem") captchaElem: ReCaptcha2Component; @ViewChild("captchaContainer", { static: false }) captchaContainer: ElementRef; @@ -93,6 +95,7 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy, { label: $localize`:@@3rdParty:3rd Party`, value: '3rd_party' } ]; partners: SelectItem[] = []; + dealers: SelectItem[] = []; filteredPlaces: BoundLocation[]; error: { code?: string, message: string } | null = null; @@ -182,6 +185,7 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy, private readonly uniqueUserValidator: UniqueUserValidator, private readonly commonSvc: CommonService, private readonly partnerSvc: PartnerService, + private readonly dealerSvc: DealerService, private readonly route: ActivatedRoute, private readonly gaService: GAService ) { @@ -224,6 +228,7 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy, refSources: this.fb.array([]) }), [this.partner]: [''], + [this.dealer]: [''], recaptcha: ['', Validators.required], lang: [this.authSvc.locale, Validators.required], [this.password]: ['', [Validators.required, Validators.minLength(8)]], @@ -258,6 +263,19 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy, this.lang = this.authSvc.locale; this.setupFormSubscriptions(); }), + switchMap(() => this.dealerSvc.getAll()), + tap((dealers: any[]) => { + this.dealers = [ + { label: $localize`:@@none:None`, value: '' }, + ...dealers + .sort((a, b) => a.companyName.localeCompare(b.companyName)) + .map(d => ({ + label: d.country ? `${d.companyName} (${d.country})` : d.companyName, + value: d._id + })) + ]; + this.signupForm.patchValue({ [this.dealer]: '' }); + }), catchError(err => { console.error('Error during signup loading:', err); this.error = handleSignupErr({ error: err, opt: { tag: signupCode.signupLoadingError } }); diff --git a/Development/client/src/app/tools/dealers/dealer.service.ts b/Development/client/src/app/tools/dealers/dealer.service.ts new file mode 100644 index 0000000..a8bc1c2 --- /dev/null +++ b/Development/client/src/app/tools/dealers/dealer.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +export interface Dealer { + _id?: string; + companyName: string; + country: string; + contactName?: string; + address?: string; + phone?: string; + cell?: string; + fax?: string; + email?: string; + website?: string; + isCertifiedRepair?: boolean; + notes?: string; + createdAt?: string; + updatedAt?: string; +} + +@Injectable({ providedIn: 'root' }) +export class DealerService { + private readonly base = '/dealers'; + + constructor(private readonly http: HttpClient) {} + + getAll(): Observable { + return this.http.get(this.base); + } + + create(dealer: Dealer): Observable { + return this.http.post(this.base, dealer); + } + + update(id: string, dealer: Dealer): Observable { + return this.http.put(`${this.base}/${id}`, dealer); + } + + delete(id: string): Observable<{ ok: boolean }> { + return this.http.delete<{ ok: boolean }>(`${this.base}/${id}`); + } +} diff --git a/Development/client/src/app/tools/dealers/dealers-routing.module.ts b/Development/client/src/app/tools/dealers/dealers-routing.module.ts new file mode 100644 index 0000000..62175a6 --- /dev/null +++ b/Development/client/src/app/tools/dealers/dealers-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { AuthGuard } from '../../domain/guards/auth.guard'; +import { SettingsGuard } from '../../domain/guards/settings-guard.service'; +import { RoleIds } from '../../shared/global'; +import { DealersComponent } from './dealers.component'; + +const routes: Routes = [ + { + path: '', + component: DealersComponent, + data: { roles: [RoleIds.ADMIN] }, + canActivate: [AuthGuard, SettingsGuard] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [AuthGuard] +}) +export class DealersRoutingModule { } diff --git a/Development/client/src/app/tools/dealers/dealers.component.css b/Development/client/src/app/tools/dealers/dealers.component.css new file mode 100644 index 0000000..f68087e --- /dev/null +++ b/Development/client/src/app/tools/dealers/dealers.component.css @@ -0,0 +1,12 @@ +.dealer-label { + display: block; + font-weight: 600; + font-size: 0.82rem; + color: #555; + margin-bottom: 0.25rem; + margin-top: 0.625rem; +} + +.dealer-required { + color: #f44336; +} diff --git a/Development/client/src/app/tools/dealers/dealers.component.html b/Development/client/src/app/tools/dealers/dealers.component.html new file mode 100644 index 0000000..878997b --- /dev/null +++ b/Development/client/src/app/tools/dealers/dealers.component.html @@ -0,0 +1,176 @@ +
+
+
+ + + + Dealers + + + + + + {{ col.header }} + + + + + + +
+ + +
+ + +
+ + +
+ + + + + Company + {{ row.companyName }} + + + Country + {{ row.country }} + + + Contact + {{ row.contactName }} + + + Phone + {{ row.phone }} + + + Email + {{ row.email }} + + + Certified + + + + Website + + open_in_new + + + + + + + + + store + No dealers found. + + + + + + {{ state.totalRecords | i18nPlural: totalItems }} + + +
+ +
+ + + + +
+
+
+
+ + + + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ + +
+ +
+ + + + + +
diff --git a/Development/client/src/app/tools/dealers/dealers.component.ts b/Development/client/src/app/tools/dealers/dealers.component.ts new file mode 100644 index 0000000..4076bfd --- /dev/null +++ b/Development/client/src/app/tools/dealers/dealers.component.ts @@ -0,0 +1,131 @@ +import { Component, OnInit } from '@angular/core'; +import { BaseComp } from '@app/shared/base/base.component'; +import { Dealer, DealerService } from './dealer.service'; + +@Component({ + selector: 'agm-dealers', + templateUrl: './dealers.component.html', + styleUrls: ['./dealers.component.css'] +}) +export class DealersComponent extends BaseComp implements OnInit { + + dealers: Dealer[] = []; + selectedDealer: Dealer | null = null; + loading = false; + + showDialog = false; + isEditing = false; + saving = false; + + form: Dealer = this.emptyForm(); + + cols = [ + { field: 'companyName', header: 'Company', width: '22%', filtered: true, filterType: 'text' }, + { field: 'country', header: 'Country', width: '14%', filtered: true, filterType: 'dropdown' }, + { field: 'contactName', header: 'Contact', width: '16%', filtered: true, filterType: 'text' }, + { field: 'phone', header: 'Phone', width: '13%', filtered: true, filterType: 'text' }, + { field: 'email', header: 'Email', width: '16%', filtered: true, filterType: 'text' }, + { field: 'isCertifiedRepair', header: 'Certified', width: '7%', filtered: true, filterType: 'dropdown' }, + { field: 'website', header: 'Website', width: '12%', filtered: false }, + ]; + + countryOptions: { label: string; value: string | null }[] = []; + + certifiedOptions = [ + { label: 'All', value: null }, + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ]; + + totalItems = { '=0': 'No dealers', '=1': '1 dealer', 'other': '# dealers' }; + + constructor(private readonly dealerSvc: DealerService) { + super(); + } + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.dealerSvc.getAll().subscribe({ + next: (data) => { + this.dealers = data; + this.countryOptions = [ + { label: 'All', value: null }, + ...Array.from(new Set(data.map(d => d.country))).sort() + .map(c => ({ label: c, value: c })) + ]; + this.loading = false; + }, + error: (err) => { + this.msgSvc.addFailedMsg('Failed to load dealers: ' + (err?.error?.error?.message || err.message)); + this.loading = false; + } + }); + } + + openNew(): void { + this.form = this.emptyForm(); + this.isEditing = false; + this.showDialog = true; + } + + openEdit(): void { + if (!this.selectedDealer) { return; } + this.form = { ...this.selectedDealer }; + this.isEditing = true; + this.showDialog = true; + } + + saveDealer(): void { + if (!this.form.companyName?.trim() || !this.form.country?.trim()) { return; } + this.saving = true; + + const action$ = this.isEditing + ? this.dealerSvc.update(this.form._id, this.form) + : this.dealerSvc.create(this.form); + + action$.subscribe({ + next: () => { + this.msgSvc.addSuccessMsg(this.isEditing ? 'Dealer updated.' : 'Dealer created.'); + this.showDialog = false; + this.selectedDealer = null; + this.saving = false; + this.load(); + }, + error: (err) => { + this.msgSvc.addFailedMsg('Save failed: ' + (err?.error?.error?.message || err.message)); + this.saving = false; + } + }); + } + + deleteDealer(): void { + if (!this.selectedDealer) { return; } + this.confirmSvc.confirm({ + message: `Delete dealer "${this.selectedDealer.companyName}"?`, + header: 'Confirm Delete', + icon: 'pi pi-exclamation-triangle', + accept: () => { + this.dealerSvc.delete(this.selectedDealer._id).subscribe({ + next: () => { + this.msgSvc.addSuccessMsg('Dealer deleted.'); + this.selectedDealer = null; + this.load(); + }, + error: (err) => this.msgSvc.addFailedMsg('Delete failed: ' + (err?.error?.error?.message || err.message)) + }); + } + }); + } + + private emptyForm(): Dealer { + return { + companyName: '', country: '', contactName: '', address: '', + phone: '', cell: '', fax: '', email: '', website: '', + isCertifiedRepair: false, notes: '' + }; + } +} diff --git a/Development/client/src/app/tools/dealers/dealers.module.ts b/Development/client/src/app/tools/dealers/dealers.module.ts new file mode 100644 index 0000000..9cdb31d --- /dev/null +++ b/Development/client/src/app/tools/dealers/dealers.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; + +import { DialogModule } from 'primeng-lts/dialog'; +import { ConfirmDialogModule } from 'primeng-lts/confirmdialog'; +import { TableModule } from 'primeng-lts/table'; +import { CheckboxModule } from 'primeng-lts/checkbox'; + +import { AppSharedModule } from '../../shared/app-shared.module'; +import { DealersRoutingModule } from './dealers-routing.module'; +import { DealersComponent } from './dealers.component'; + +@NgModule({ + imports: [ + AppSharedModule, + DialogModule, + ConfirmDialogModule, + TableModule, + CheckboxModule, + DealersRoutingModule + ], + declarations: [DealersComponent] +}) +export class DealersModule { } diff --git a/Development/server/controllers/customer.js b/Development/server/controllers/customer.js index d41ac24..e20e090 100644 --- a/Development/server/controllers/customer.js +++ b/Development/server/controllers/customer.js @@ -112,7 +112,8 @@ async function getCustomer_get(req, res) { const view = req.query.view; let query = Customer.findOne({ _id: ObjectId(cId) }, null, { lean: true }) .populate({ path: 'Country', select: 'code name -_id' }) - .populate({ path: 'partner', select: 'name description' }); + .populate({ path: 'partner', select: 'name description' }) + .populate({ path: 'dealer', select: 'companyName country contactName phone email' }); if (view !== 'edit') { query = query.select('-password'); diff --git a/Development/server/controllers/dealer.js b/Development/server/controllers/dealer.js new file mode 100644 index 0000000..5bf3b88 --- /dev/null +++ b/Development/server/controllers/dealer.js @@ -0,0 +1,46 @@ +'use strict'; + +const Dealer = require('../model/dealer'), + { AppParamError } = require('../helpers/app_error'), + assert = require('assert'); + +async function getDealers_get(req, res) { + const dealers = await Dealer.find().sort({ country: 1, companyName: 1 }).lean(); + res.json(dealers); +} + +async function createDealer_post(req, res) { + const body = req.body; + assert(body && body.companyName && body.country, AppParamError.create()); + + delete body._id; + const dealer = new Dealer(body); + const saved = await dealer.save(); + res.json(saved); +} + +async function updateDealer_put(req, res) { + const { id } = req.params; + const body = req.body; + assert(id, AppParamError.create()); + + const updated = await Dealer.findByIdAndUpdate(id, body, { new: true, runValidators: true }); + if (!updated) AppParamError.throw(); + res.json(updated); +} + +async function deleteDealer_delete(req, res) { + const { id } = req.params; + assert(id, AppParamError.create()); + + const deleted = await Dealer.findByIdAndDelete(id); + if (!deleted) AppParamError.throw(); + res.json({ ok: true }); +} + +module.exports = { + getDealers_get, + createDealer_post, + updateDealer_put, + deleteDealer_delete +}; diff --git a/Development/server/controllers/user.js b/Development/server/controllers/user.js index b36a449..2f80c69 100644 --- a/Development/server/controllers/user.js +++ b/Development/server/controllers/user.js @@ -487,6 +487,9 @@ async function signup_post(req, res) { if (typeof input.partner === 'string' && input.partner?.trim().length !== 0) { customerData.partner = input.partner.trim(); } + if (typeof input.dealer === 'string' && input.dealer?.trim().length !== 0) { + customerData.dealer = input.dealer.trim(); + } const newCustomer = new CustomerModel(customerData); const emailData = { diff --git a/Development/server/middlewares/app_validator.js b/Development/server/middlewares/app_validator.js index 0691d02..e88a220 100644 --- a/Development/server/middlewares/app_validator.js +++ b/Development/server/middlewares/app_validator.js @@ -27,6 +27,7 @@ function isSecuredRoute(routePath, method) { { path: '/resetPassword', method: 'ALL' }, { path: '/signup', method: 'ALL' }, { path: '/api/partners', method: 'GET', exact: true }, // Allow unauthenticated GET /api/partners only (not subroutes) + { path: '/api/dealers', method: 'GET', exact: true }, // Allow unauthenticated GET /api/dealers only (used on signup page) { path: '/exists', method: 'POST' }, { path: '/countries', method: 'GET' }, { path: '/testAuth', method: 'ALL' }, diff --git a/Development/server/model/dealer.js b/Development/server/model/dealer.js new file mode 100644 index 0000000..639e03b --- /dev/null +++ b/Development/server/model/dealer.js @@ -0,0 +1,32 @@ +'use strict'; + +const mongoose = require('mongoose'), + Schema = mongoose.Schema; + +const dealerSchema = new Schema({ + companyName: { type: String, required: true, trim: true }, + country: { type: String, required: true, trim: true }, + contactName: { type: String, trim: true, default: '' }, + address: { type: String, trim: true, default: '' }, + phone: { type: String, trim: true, default: '' }, + cell: { type: String, trim: true, default: '' }, + fax: { type: String, trim: true, default: '' }, + email: { type: String, trim: true, lowercase: true, default: '' }, + website: { type: String, trim: true, default: '' }, + isCertifiedRepair: { type: Boolean, default: false }, + notes: { type: String, trim: true, default: '' }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now } +}, { strictQuery: false }); + +dealerSchema.pre('save', function (next) { + this.updatedAt = new Date(); + next(); +}); + +dealerSchema.pre('findOneAndUpdate', function (next) { + this.set({ updatedAt: new Date() }); + next(); +}); + +module.exports = mongoose.model('Dealer', dealerSchema); diff --git a/Development/server/model/user.js b/Development/server/model/user.js index 6c3d703..a0db49e 100644 --- a/Development/server/model/user.js +++ b/Development/server/model/user.js @@ -46,6 +46,9 @@ const schema = new Schema({ // Reference to the Partner collection (optional) - used for customers with partner integrations partner: { type: Schema.Types.ObjectId, ref: 'User', required: false }, + // Reference to the Dealer collection (optional) - the dealer that sold/supports this customer + dealer: { type: Schema.Types.ObjectId, ref: 'Dealer', required: false }, + loggedInAt: { type: Date }, markedDelete: { type: Boolean, default: false }, diff --git a/Development/server/routes/dealer.js b/Development/server/routes/dealer.js new file mode 100644 index 0000000..8841071 --- /dev/null +++ b/Development/server/routes/dealer.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = function (app) { + const router = require('express').Router(), + { authAllowAdmin } = require('../middlewares/validate'), + dealerCtl = require('../controllers/dealer'); + + router.get('/', dealerCtl.getDealers_get); + router.post('/', authAllowAdmin(), dealerCtl.createDealer_post); + router.put('/:id', authAllowAdmin(), dealerCtl.updateDealer_put); + router.delete('/:id', authAllowAdmin(), dealerCtl.deleteDealer_delete); + + app.use('/api/dealers', router); +}; diff --git a/Development/server/routes/index.js b/Development/server/routes/index.js index 4b0d5b8..99d7149 100644 --- a/Development/server/routes/index.js +++ b/Development/server/routes/index.js @@ -29,6 +29,7 @@ module.exports = function (app) { require('./costing_items')(app); require('./log_payment')(app); require('./partner')(app); + require('./dealer')(app); require('./health')(app); // Data Export public API (X-API-Key auth) and key management (JWT auth) require('./api_pub')(app); diff --git a/Development/server/routes/user.js b/Development/server/routes/user.js index 80fe339..8d31b1d 100644 --- a/Development/server/routes/user.js +++ b/Development/server/routes/user.js @@ -53,6 +53,7 @@ module.exports = function (app) { taxId: Joi.string().allow('').optional(), lang: Joi.string().default(DEFAULT_LANG).optional(), partner: Joi.objectId().allow('').allow(null).optional(), + dealer: Joi.objectId().allow('').allow(null).optional(), emailToken: Joi.string().optional(), token: Joi.string().optional(), }) diff --git a/Development/server/scripts/seedDealers.js b/Development/server/scripts/seedDealers.js new file mode 100644 index 0000000..77c23e6 --- /dev/null +++ b/Development/server/scripts/seedDealers.js @@ -0,0 +1,303 @@ +'use strict'; + +/** + * Dealer Seed Script + * + * Inserts the initial AG-NAV world-wide dealer network into the database. + * Safe to re-run: upserts on (companyName + country) so no duplicates are created. + * + * Usage (from repo root): + * set -a && source server/environment.env && set +a && DEBUG=agm:seed-dealers node server/scripts/seedDealers.js + * + * Dry-run (no writes): + * set -a && source server/environment.env && set +a && DEBUG=agm:seed-dealers node server/scripts/seedDealers.js --dry-run + */ + +const debug = require('debug')('agm:seed-dealers'); +const { DBConnection } = require('../helpers/db/connect.js'); +const Dealer = require('../model/dealer.js'); + +const args = process.argv.slice(2); +const isDryRun = args.includes('--dry-run'); + +if (isDryRun) { + debug('Running in DRY-RUN mode — no changes will be made'); +} + +const DEALERS = [ + { + companyName: 'Aerotec', + country: 'Argentina', + contactName: 'Diego M. Cardama Mendoza', + address: 'Aerodromo Mario Cardama (5577) Comandante Torres 100 – Rivadavia Buenos Aires; Aerodromo Aeroclub Lujan – Beschtedt S/N Hangar 1', + phone: '+54 (263) 444 3212', + cell: '+54 (9) 261 569 2744', + email: 'diego@aerotec-argentina.com.ar', + website: 'http://aerotec.com.ar/', + isCertifiedRepair: false, + }, + { + companyName: 'APAC Heli Solutions', + country: 'Australia', + contactName: 'Scott Simpson', + address: 'Kurrajong NSW 2758, Australia', + phone: '+61 (0) 418 484 515', + email: 'ssimpson@apachelisolutions.com', + website: 'https://www.apachelisolutions.com/', + isCertifiedRepair: false, + }, + { + companyName: 'Trabajo Aereo Agricola T.A.A', + country: 'Bolivia', + contactName: 'Verly Valdez Ruiz', + address: 'Lagunillas # 301 esq. Villamontes, Braniff Santa Cruz, Bolivia', + phone: '591 3 352 6578', + cell: '591 716 48864', + email: 'verlyvaldez@hotmail.com', + isCertifiedRepair: false, + }, + { + companyName: 'DGPS & CIA', + country: 'Brazil', + contactName: 'Miguel Paim', + address: 'Rua dos Hangares No. 453 Bairro: Parque Industrial – Aeroporto, Primavera do Leste – MT, CEP: 78850-000', + phone: '+55 (66) 3497-3400', + cell: '+55 (66) 9986-1198', + email: 'miguelpaim@dgpsecia.com.br', + website: 'http://www.dgpsecia.com.br', + isCertifiedRepair: true, + }, + { + companyName: 'Dinnarc Tecnologia Agricola', + country: 'Brazil', + contactName: 'Augusto e Ramon', + address: 'Avenida Adolino Bedin Nº 875, CEP: 78.894-132 Jardim das Américas, Sorriso, MT, Brazil', + phone: '+55 (66) 99981-8300', + cell: '+55 (66) 99200-7447', + email: 'contato@dinnarc.com.br', + isCertifiedRepair: false, + }, + { + companyName: 'ABA Manutencao de Aeronaves', + country: 'Brazil', + contactName: 'Ruddiger Alves Da Silva', + address: 'Rua da Prainha, 3320 Cond. Sitio de Voo ABA Lotes 05 e 06, Barreirinhas, Barreiras', + phone: '+55 (66) 99987-0727', + email: 'ruddigger@abamanutencao.com.br', + isCertifiedRepair: false, + }, + { + companyName: 'Galindo e Galindo Comercio e Servicos Eletronicos Ltda', + country: 'Brazil', + contactName: 'Francisco Galindo', + address: 'Rua D, 20 - Balneario Recreativa de Campo, CEP: 14.073-808, Ribeirao Preto - SP', + phone: '+55 (16) 3629-3317', + cell: '+55 (16) 99137-1517', + email: 'fgalindo@galindodgps.com.br', + website: 'http://www.galindodgps.com.br', + isCertifiedRepair: false, + }, + { + companyName: 'Aeroglobo Aeronaves', + country: 'Brazil', + address: 'Rua José Dal Farra, 654, Jardim Dona Carolina, Botucatu – São Paulo, 18.602.020', + phone: '+55 14 3814-3450', + email: 'contato@aeroglobo.com.br', + website: 'https://www.aeroglobo.com.br', + isCertifiedRepair: false, + }, + { + companyName: 'Provincial Airways', + country: 'Canada', + contactName: 'James', + address: 'Box 2170, Hwy 301N, Moose Jaw, SK, S6H 7T2', + phone: '306 692 7335', + fax: '306 693 5288', + cell: '306 693 0877', + email: 'james@provincialairways.net', + website: 'http://www.provincialairways.net', + isCertifiedRepair: false, + }, + { + companyName: 'Carlos Ilabaca Vacarezza', + country: 'Chile', + contactName: 'Carlos Ilabaca Vacarezza', + address: 'La Serena, Chile', + phone: '56 097 849 0244', + email: 'cpilabaca@gmail.com', + isCertifiedRepair: true, + }, + { + companyName: 'Jarly Camacho Torres', + country: 'Colombia', + contactName: 'Jarly Camacho Torres', + address: 'Calle 6, casa # 12-52 Barrio Pescadito, Santa Marta, Colombia', + phone: '(57) 431-6187', + cell: '(57) 321-525-6367', + fax: '(57) 317-525-4385', + email: 'jcamachot87@yahoo.es', + isCertifiedRepair: true, + }, + { + companyName: 'Mario Berrones Corp', + country: 'Ecuador', + contactName: 'Mario Berrones', + address: 'Eloy Alfaro 126 y Tarqui, Canton Yaguachi, Guayas, Ecuador', + phone: '011-593-93910438', + email: 'mabescorp@hotmail.com', + isCertifiedRepair: true, + }, + { + companyName: 'Aero Agricola Paraguaya SA', + country: 'Paraguay', + address: 'Ruta 1 KM 286, Aeropuerto Aero Agricola Paraguaya, General Delgado, Itapua 6860, Paraguay', + phone: '+595 985-220286', + email: 'administracion@aeroagricolaparaguaya.com.py', + isCertifiedRepair: false, + }, + { + companyName: 'Davao Aerowurkz Corporation', + country: 'Philippines', + address: 'BTC Hangar, Old Airport, Sasa, Davao City, 8000, Philippines', + phone: '+63 (082) 234-8843', + email: 'aerowurks_aviation@yahoo.com.ph', + isCertifiedRepair: false, + }, + { + companyName: 'Business Development Services', + country: 'Poland', + contactName: 'Lukasz Kempys', + address: 'UL. USTRONIE 31, NOWY TARG, 34-400, Poland', + phone: '+48-694-473-616', + email: 'lukaszkempys@gmail.com', + website: 'http://www.SkyFun.pl', + isCertifiedRepair: true, + }, + { + companyName: 'Steve Viviers Aviation', + country: 'South Africa', + contactName: 'Steve Viviers', + address: 'P.O. Box 1952, Kroonstad, 9500, South Africa', + phone: '+27-836378504', + fax: '+27-56-212-3436', + cell: '+27-82-800-1508', + email: 'viviersaviation@act.co.za', + isCertifiedRepair: true, + }, + { + companyName: 'Lane Aviation', + country: 'United States', + contactName: 'Dona Jorden', + address: '3205 FM 2218 Rd, Rosenberg, TX 77471, USA', + phone: '(281) 342-5451', + fax: '(281) 232-5401', + email: 'dona@laneav.com', + isCertifiedRepair: false, + }, + { + companyName: 'Frost Flying Inc.', + country: 'United States', + contactName: 'Garret Frost', + address: '3393 Hwy. 121 West, Marianna, AR 72360, USA', + phone: '(870) 295-6218', + fax: '(870) 295-6237', + email: 'frostparts@hotmail.com', + isCertifiedRepair: false, + }, + { + companyName: 'Crosslands International, LLC', + country: 'United States', + contactName: 'John M. Mishler', + address: '17921 S US Hwy 377, Cresson, TX 76035, USA', + phone: '(817) 478-9933', + email: 'john@crosslandsinternational.com', + isCertifiedRepair: false, + }, + { + companyName: 'Summit Helicopters, Inc.', + country: 'United States', + contactName: 'Jeff Partain', + address: 'Box 909, 525 McCelland Street, Salem, VA 24153, USA', + phone: '(540) 375-8909', + email: 'jeff.partain@summithelicopters.com', + isCertifiedRepair: false, + }, + { + companyName: 'Thomas Helicopters', + country: 'United States', + contactName: 'Rod Thomas', + address: '1553 South 1800 East, Gooding, ID 83330, USA', + phone: '(208) 934-8298', + fax: '(208) 934-5934', + email: 'rodheli@aol.com', + isCertifiedRepair: false, + }, + { + companyName: 'Collective Aviation', + country: 'United States', + contactName: 'Kristopher Petter', + address: '2874 Henry Wallace Rd., Orient, Iowa 50858, USA', + phone: '(206) 484-8749', + email: 'kris@collectiveaviationservices.com', + isCertifiedRepair: false, + }, + { + companyName: 'Servicio Aeroagricola De Flores S.R.L.', + country: 'Uruguay', + contactName: 'Julio Placeres / Juan Perez', + address: 'Manuel Irazabal 530, Trinidad, Flores, Uruguay', + phone: '+598 4364-4686', + fax: '+598 4364-4686', + email: 'juliopla@adinet.com.uy', + isCertifiedRepair: true, + }, +]; + +async function seedDealers() { + debug(`Seeding ${DEALERS.length} dealers (dry-run: ${isDryRun})`); + + let inserted = 0, skipped = 0; + + for (const dealer of DEALERS) { + const filter = { companyName: dealer.companyName, country: dealer.country }; + + if (isDryRun) { + const existing = await Dealer.findOne(filter).lean(); + if (existing) { + debug(`[DRY-RUN] Would skip — ${dealer.companyName} (${dealer.country}) already exists`); + skipped++; + } else { + debug(`[DRY-RUN] Would insert — ${dealer.companyName} (${dealer.country})`); + inserted++; + } + continue; + } + + const existing = await Dealer.findOne(filter).lean(); + if (existing) { + debug(`[skipped] ${dealer.companyName} (${dealer.country})`); + skipped++; + } else { + await Dealer.create(dealer); + debug(`[inserted] ${dealer.companyName} (${dealer.country})`); + inserted++; + } + } + + debug(`Done. Inserted: ${inserted}, Skipped: ${skipped}`); +} + +const workerDB = new DBConnection('Dealer Seed Script'); + +workerDB.initialize({ + setupExitHandlers: false, + onReady: async () => { + try { + await seedDealers(); + process.exit(0); + } catch (err) { + debug('Seed failed:', err); + process.exit(1); + } + } +});