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 @@
+
+
+
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);
+ }
+ }
+});