copy of data-export-api branch as of April 22 2026
This commit is contained in:
parent
0836fc0fbc
commit
9ea0a43ae7
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Dependencies
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
*.rlog
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
**/*.env
|
||||||
|
**/environment.env
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
**/dist/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
149
Development/client/docs/BROWSER_CACHE_SERVICE.md
Normal file
149
Development/client/docs/BROWSER_CACHE_SERVICE.md
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# BrowserCacheService
|
||||||
|
|
||||||
|
**File**: `src/app/domain/services/browser-cache.service.ts`
|
||||||
|
|
||||||
|
A generic, injectable Angular service that provides a typed read/write/invalidate API over the browser's [Cache Storage API](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage). Intended as a shared foundation for any feature that wants to cache HTTP responses across navigation events without a Service Worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why Cache Storage?
|
||||||
|
|
||||||
|
| Mechanism | Survives navigation | Survives page reload | Configurable TTL | Storage limit |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Component state | ✗ | ✗ | — | Memory |
|
||||||
|
| NgRx store | ✓ (same tab) | ✗ | — | Memory |
|
||||||
|
| `sessionStorage` | ✓ | ✗ | Manual | ~5 MB |
|
||||||
|
| `localStorage` | ✓ | ✓ | Manual | ~5 MB |
|
||||||
|
| **Cache Storage** | ✓ | ✓ | ✓ (per entry) | Quota-managed |
|
||||||
|
|
||||||
|
Cache Storage was chosen because it:
|
||||||
|
- Is available in all modern browsers (Chrome 40+, Firefox 44+, Safari 11.1+)
|
||||||
|
- Stores structured data alongside an expiry timestamp without size pressure
|
||||||
|
- Is already used by Service Workers and the browser's native HTTP cache, so quota management is handled by the browser
|
||||||
|
- Falls back gracefully (service becomes a no-op) when unavailable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
class BrowserCacheService {
|
||||||
|
|
||||||
|
get<T>(cacheName: string, key: string, maxAgeMs?: number): Observable<T | null>
|
||||||
|
|
||||||
|
put<T>(cacheName: string, key: string, data: T): void
|
||||||
|
|
||||||
|
invalidate(cacheName: string): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `get<T>(cacheName, key, maxAgeMs?)`
|
||||||
|
|
||||||
|
Returns an `Observable` that emits the cached value (`T`) or `null` when:
|
||||||
|
|
||||||
|
- The Cache Storage API is unavailable (e.g. older browser, unit test environment)
|
||||||
|
- No entry exists for the given `cacheName` + `key` combination
|
||||||
|
- The entry is older than `maxAgeMs` (default: `60 000` ms / 1 minute)
|
||||||
|
|
||||||
|
Errors from the Cache API are caught and converted to `null` — they never propagate to the caller.
|
||||||
|
|
||||||
|
### `put<T>(cacheName, key, data)`
|
||||||
|
|
||||||
|
Stores `data` in the named cache bucket under `key`. A `cachedAt` timestamp is embedded alongside the data so staleness can be checked on the next `get`.
|
||||||
|
|
||||||
|
Fire-and-forget: errors are silently swallowed.
|
||||||
|
|
||||||
|
### `invalidate(cacheName)`
|
||||||
|
|
||||||
|
Deletes the **entire** Cache Storage bucket for `cacheName`. This removes all entries for that feature in one call.
|
||||||
|
|
||||||
|
Fire-and-forget: errors are silently swallowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache key format
|
||||||
|
|
||||||
|
Internally, entries are stored under a pseudo-URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
/browser-cache/<encodedCacheName>?<key>
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps entries within a single Cache Storage bucket readable via browser DevTools (Application → Cache Storage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new feature cache
|
||||||
|
|
||||||
|
Create a typed facade service that delegates to `BrowserCacheService`. This keeps the cache name and TTL in one place and gives callers a clean domain API.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/domain/services/customer-cache.service.ts
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { BrowserCacheService } from './browser-cache.service';
|
||||||
|
import { ICustomer } from '../../customers/models/customer.model';
|
||||||
|
|
||||||
|
const CACHE_NAME = 'agm-customer-list-v1';
|
||||||
|
const MAX_AGE_MS = 60_000; // 1 minute
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class CustomerCacheService {
|
||||||
|
|
||||||
|
constructor(private readonly browserCache: BrowserCacheService) {}
|
||||||
|
|
||||||
|
get(queryParams: string): Observable<ICustomer[] | null> {
|
||||||
|
return this.browserCache.get<ICustomer[]>(CACHE_NAME, queryParams, MAX_AGE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
put(queryParams: string, data: ICustomer[]): void {
|
||||||
|
this.browserCache.put(CACHE_NAME, queryParams, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
this.browserCache.invalidate(CACHE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, in the corresponding service:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In CustomerService.loadCustomers():
|
||||||
|
const cacheKey = params.toString();
|
||||||
|
|
||||||
|
return this.customerCache.get(cacheKey).pipe(
|
||||||
|
switchMap(cached => {
|
||||||
|
if (cached !== null) return of(cached);
|
||||||
|
return this.http.get<ICustomer[]>(this.url, { params }).pipe(
|
||||||
|
tap(data => this.customerCache.put(cacheKey, data))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
And in the effects, call `this.customerCache.invalidate()` after any create / update / delete action succeeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Existing implementations
|
||||||
|
|
||||||
|
| Feature | Facade | Cache name | TTL |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Job list | `JobCacheService` | `agm-jobs-list-v1` | 60 s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versioning the cache name
|
||||||
|
|
||||||
|
Append a version suffix (e.g. `-v1`, `-v2`) to `cacheName` whenever the shape of the stored data changes. The old bucket will be orphaned in the browser until the browser's quota manager evicts it, or you can explicitly delete the old name during app initialisation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Browser DevTools
|
||||||
|
|
||||||
|
Cached entries are visible under:
|
||||||
|
|
||||||
|
**Chrome DevTools** → Application tab → Cache Storage → `agm-jobs-list-v1`
|
||||||
35362
Development/client/package-lock.json
generated
Normal file
35362
Development/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -22,6 +22,11 @@
|
|||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
[value]="dt.filters[col.field]?.value">
|
[value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
|
<p-dropdown *ngIf="col.field === ACTIVE" [options]="activeOpts" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === KIND">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -21,6 +21,11 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
readonly resolveFieldData = Utils.resolveFieldData;
|
readonly resolveFieldData = Utils.resolveFieldData;
|
||||||
readonly KIND = 'kind';
|
readonly KIND = 'kind';
|
||||||
readonly ACTIVE = OperationalStatus.ACTIVE;
|
readonly ACTIVE = OperationalStatus.ACTIVE;
|
||||||
|
activeOpts = [
|
||||||
|
{ label: globals.all, value: null },
|
||||||
|
{ label: globals.active, value: true },
|
||||||
|
{ label: globals.notActive, value: false },
|
||||||
|
];
|
||||||
accounts: Array<User>;
|
accounts: Array<User>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
currAcc: User;
|
currAcc: User;
|
||||||
|
|||||||
@ -69,6 +69,11 @@ const routes: Routes = [
|
|||||||
loadChildren: () => import('./tools/tools.module').then(m => m.ToolsModule),
|
loadChildren: () => import('./tools/tools.module').then(m => m.ToolsModule),
|
||||||
runGuardsAndResolvers: 'always',
|
runGuardsAndResolvers: 'always',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'dlq',
|
||||||
|
loadChildren: () => import('./tools/dlq-monitor/dlq-monitor.module').then(m => m.DlqMonitorModule),
|
||||||
|
runGuardsAndResolvers: 'always',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'track',
|
path: 'track',
|
||||||
loadChildren: () => import('./track/track.module').then(m => m.TrackModule),
|
loadChildren: () => import('./track/track.module').then(m => m.TrackModule),
|
||||||
@ -90,6 +95,11 @@ const routes: Routes = [
|
|||||||
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
|
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
|
||||||
runGuardsAndResolvers: 'always'
|
runGuardsAndResolvers: 'always'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'api-keys',
|
||||||
|
loadChildren: () => import('./settings/api-keys/api-keys.module').then(m => m.ApiKeysModule),
|
||||||
|
runGuardsAndResolvers: 'always'
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -27,7 +27,16 @@
|
|||||||
|
|
||||||
<app-topbar></app-topbar>
|
<app-topbar></app-topbar>
|
||||||
|
|
||||||
|
<!-- Mobile-only left-edge tab to open/close the navigation panel -->
|
||||||
|
<button id="mobile-menu-tab" (click)="onMenuButtonClick($event)" aria-label="Toggle navigation">
|
||||||
|
<i class="material-icons">chevron_right</i>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="layout-menu" [ngClass]="{'layout-menu-dark':darkMenu}" (click)="onMenuClick($event)">
|
<div class="layout-menu" [ngClass]="{'layout-menu-dark':darkMenu}" (click)="onMenuClick($event)">
|
||||||
|
<div class="menu-user-info" *ngIf="user$ | async as user">
|
||||||
|
<app-inline-profile [user]="user" [expiryWarning]="expiryWarning$ | async"
|
||||||
|
(navigateToSubscription)="onNavigateToManageSubscription()"></app-inline-profile>
|
||||||
|
</div>
|
||||||
<app-menu></app-menu>
|
<app-menu></app-menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,15 @@ export class AppMenuComponent implements OnInit {
|
|||||||
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
|
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
|
||||||
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
|
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
|
||||||
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
|
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
|
||||||
|
{
|
||||||
|
id: 'tools',
|
||||||
|
label: $localize`:@@tools:Tools`, icon: 'extension',
|
||||||
|
routerLink: ['/tools'],
|
||||||
|
items: [
|
||||||
|
{ id: 'api-keys', label: $localize`:@@apiKeys:API Keys`, icon: 'vpn_key', routerLink: ['/api-keys'] },
|
||||||
|
{ id: 'dlq-monitor', label: $localize`:@@dlqMonitor:DLQ Monitor`, icon: 'bug_report', routerLink: ['/dlq'] }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
label: $localize`:@@settings:Settings`, icon: 'settings',
|
label: $localize`:@@settings:Settings`, icon: 'settings',
|
||||||
@ -209,7 +218,10 @@ export class AppMenuComponent implements OnInit {
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
|
{ 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: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
|
||||||
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
|
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] },
|
||||||
|
...( this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])
|
||||||
|
? [{ id: 'api-keys', label: $localize`:@@apiKeys:API Keys`, icon: 'vpn_key', routerLink: ['/api-keys'] }]
|
||||||
|
: [] )
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,15 +8,18 @@
|
|||||||
|
|
||||||
.account-summary-info .account-username {
|
.account-summary-info .account-username {
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
|
text-align:center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-summary-info .account-type {
|
.account-summary-info .account-type {
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
|
text-align:center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-summary-info .account-contact {
|
.account-summary-info .account-contact {
|
||||||
color: #ffd700;
|
color: #ffd700;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
text-align:center;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,10 @@
|
|||||||
<i class="ui-icon-search"></i>
|
<i class="ui-icon-search"></i>
|
||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'address'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -90,6 +90,12 @@
|
|||||||
required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
|
required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
|
||||||
</agm-account-editor>
|
</agm-account-editor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key Manager (existing customers only) -->
|
||||||
|
<div class="ui-g-12" *ngIf="!isNew">
|
||||||
|
<agm-api-key-manager [ownerId]="customer._id" [toggleable]="true" [collapsed]="true"></agm-api-key-manager>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
||||||
<button pButton [disabled]="form.invalid || partnerLoading" type="button" style="width:auto"
|
<button pButton [disabled]="form.invalid || partnerLoading" type="button" style="width:auto"
|
||||||
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
|
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
|
||||||
|
|||||||
@ -30,6 +30,18 @@
|
|||||||
|
|
||||||
<p-dropdown *ngIf="col.field === PARTNER_NAME" [options]="partners" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
<p-dropdown *ngIf="col.field === PARTNER_NAME" [options]="partners" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||||
|
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'contact'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'totalJobs'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === CREATED">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { ToastModule } from 'primeng/toast';
|
|||||||
import { MessagesModule } from 'primeng/messages';
|
import { MessagesModule } from 'primeng/messages';
|
||||||
|
|
||||||
import { AppSharedModule } from '../shared/app-shared.module';
|
import { AppSharedModule } from '../shared/app-shared.module';
|
||||||
|
import { ApiKeySharedModule } from '../settings/api-keys/api-key-shared.module';
|
||||||
|
|
||||||
import { StoreModule } from '@ngrx/store';
|
import { StoreModule } from '@ngrx/store';
|
||||||
import { EffectsModule } from '@ngrx/effects';
|
import { EffectsModule } from '@ngrx/effects';
|
||||||
@ -41,6 +42,7 @@ import { TrialComponent } from './trial/trial.component';
|
|||||||
SplitButtonModule,
|
SplitButtonModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
AppSharedModule,
|
AppSharedModule,
|
||||||
|
ApiKeySharedModule,
|
||||||
|
|
||||||
StoreModule.forFeature(fromCustomers.FEATURE_KEY, fromCustomers.reducer),
|
StoreModule.forFeature(fromCustomers.FEATURE_KEY, fromCustomers.reducer),
|
||||||
EffectsModule.forFeature([CustomerEffects]),
|
EffectsModule.forFeature([CustomerEffects]),
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiKey, CreateApiKeyRequest, CreateApiKeyResponse } from '../../settings/api-keys/models/api-key.model';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyService {
|
||||||
|
private readonly apiURL = '/keys';
|
||||||
|
|
||||||
|
constructor(private readonly http: HttpClient) {}
|
||||||
|
|
||||||
|
listKeys(ownerId?: string): Observable<ApiKey[]> {
|
||||||
|
const params = ownerId ? new HttpParams().set('ownerId', ownerId) : undefined;
|
||||||
|
return this.http.get<ApiKey[]>(this.apiURL, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
createKey(req: CreateApiKeyRequest): Observable<CreateApiKeyResponse> {
|
||||||
|
return this.http.post<CreateApiKeyResponse>(this.apiURL, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeKey(keyId: string): Observable<void> {
|
||||||
|
return this.http.patch<void>(`${this.apiURL}/${keyId}/revoke`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteKey(keyId: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.apiURL}/${keyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
regenerateKey(keyId: string): Observable<CreateApiKeyResponse> {
|
||||||
|
return this.http.post<CreateApiKeyResponse>(`${this.apiURL}/${keyId}/regenerate`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { from, Observable, of } from 'rxjs';
|
||||||
|
import { catchError, switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
/** Shape of every entry stored in Cache Storage. */
|
||||||
|
export interface BrowserCacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
cachedAt: number; // epoch ms
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic browser-side Cache Storage wrapper.
|
||||||
|
*
|
||||||
|
* Each logical cache is identified by a **cacheName** (e.g. `'agm-jobs-list-v1'`).
|
||||||
|
* Within that cache, individual entries are keyed by an arbitrary **key** string
|
||||||
|
* (typically a serialised set of query params).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* // Read
|
||||||
|
* this.browserCache.get<MyModel[]>('my-cache-v1', key, 60_000).subscribe(data => { ... });
|
||||||
|
*
|
||||||
|
* // Write
|
||||||
|
* this.browserCache.put('my-cache-v1', key, data);
|
||||||
|
*
|
||||||
|
* // Invalidate
|
||||||
|
* this.browserCache.invalidate('my-cache-v1');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* All operations are no-ops when the Cache Storage API is unavailable
|
||||||
|
* (e.g. in unit tests or older browsers).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BrowserCacheService {
|
||||||
|
|
||||||
|
private readonly supported = typeof caches !== 'undefined';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the pseudo-URL used as the cache key inside a named Cache bucket.
|
||||||
|
* We prefix with a fixed path so it looks like a valid Request URL.
|
||||||
|
*/
|
||||||
|
private entryUrl(cacheName: string, key: string): string {
|
||||||
|
return `/browser-cache/${encodeURIComponent(cacheName)}?${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a cached value.
|
||||||
|
*
|
||||||
|
* @param cacheName Name of the Cache Storage bucket (e.g. `'agm-jobs-list-v1'`).
|
||||||
|
* @param key Entry key — typically serialised query params.
|
||||||
|
* @param maxAgeMs Maximum age in milliseconds before the entry is treated as stale.
|
||||||
|
* Defaults to 60 000 (1 minute).
|
||||||
|
* @returns The cached value, or `null` when unavailable / stale / missing.
|
||||||
|
*/
|
||||||
|
get<T>(cacheName: string, key: string, maxAgeMs: number = 60_000): Observable<T | null> {
|
||||||
|
if (!this.supported) return of(null);
|
||||||
|
|
||||||
|
return from(caches.open(cacheName)).pipe(
|
||||||
|
switchMap(cache => from(cache.match(this.entryUrl(cacheName, key)))),
|
||||||
|
switchMap(response => {
|
||||||
|
if (!response) return of(null);
|
||||||
|
return from(response.json() as Promise<BrowserCacheEntry<T>>);
|
||||||
|
}),
|
||||||
|
switchMap((entry: BrowserCacheEntry<T> | null) => {
|
||||||
|
if (!entry) return of(null);
|
||||||
|
if (Date.now() - entry.cachedAt > maxAgeMs) return of(null);
|
||||||
|
return of(entry.data);
|
||||||
|
}),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a value in Cache Storage.
|
||||||
|
* Fire-and-forget — errors are silently swallowed so they never block the caller.
|
||||||
|
*
|
||||||
|
* @param cacheName Name of the Cache Storage bucket.
|
||||||
|
* @param key Entry key.
|
||||||
|
* @param data Value to store (must be JSON-serialisable).
|
||||||
|
*/
|
||||||
|
put<T>(cacheName: string, key: string, data: T): void {
|
||||||
|
if (!this.supported) return;
|
||||||
|
|
||||||
|
const entry: BrowserCacheEntry<T> = { data, cachedAt: Date.now() };
|
||||||
|
caches.open(cacheName)
|
||||||
|
.then(cache => cache.put(
|
||||||
|
this.entryUrl(cacheName, key),
|
||||||
|
new Response(JSON.stringify(entry), { headers: { 'Content-Type': 'application/json' } })
|
||||||
|
))
|
||||||
|
.catch(() => { /* silent */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an entire Cache Storage bucket, invalidating all its entries.
|
||||||
|
* Typically called after a mutation (create / update / delete).
|
||||||
|
*
|
||||||
|
* @param cacheName Name of the Cache Storage bucket to delete.
|
||||||
|
*/
|
||||||
|
invalidate(cacheName: string): void {
|
||||||
|
if (!this.supported) return;
|
||||||
|
caches.delete(cacheName).catch(() => { /* silent */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { BrowserCacheService } from './browser-cache.service';
|
||||||
|
|
||||||
|
const CACHE_NAME = 'agm-jobs-list-v1';
|
||||||
|
const MAX_AGE_MS = 60_000; // 1 minute
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jobs-list-specific facade over {@link BrowserCacheService}.
|
||||||
|
*
|
||||||
|
* Encapsulates the cache name and TTL so callers (JobService, JobEffects)
|
||||||
|
* don't need to know those details.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class JobCacheService {
|
||||||
|
|
||||||
|
constructor(private readonly browserCache: BrowserCacheService) {}
|
||||||
|
|
||||||
|
/** Return cached jobs for the given query-param string, or null if stale/missing. */
|
||||||
|
get(queryParams: string): Observable<any[] | null> {
|
||||||
|
return this.browserCache.get<any[]>(CACHE_NAME, queryParams, MAX_AGE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store a fresh jobs list for the given query-param string. */
|
||||||
|
put(queryParams: string, data: any[]): void {
|
||||||
|
this.browserCache.put(CACHE_NAME, queryParams, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalidate all cached job-list entries (call after any job mutation). */
|
||||||
|
invalidate(): void {
|
||||||
|
this.browserCache.invalidate(CACHE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -2,11 +2,12 @@ import { Injectable } from '@angular/core';
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map, switchMap, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model';
|
import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model';
|
||||||
import { AppFile } from '../models/shared.model';
|
import { AppFile } from '../models/shared.model';
|
||||||
import { UpdateJobOps } from '../../job/actions/job.actions';
|
import { UpdateJobOps } from '../../job/actions/job.actions';
|
||||||
|
import { JobCacheService } from './job-cache.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobService {
|
export class JobService {
|
||||||
@ -14,27 +15,49 @@ export class JobService {
|
|||||||
private readonly jobURL = '/jobs';
|
private readonly jobURL = '/jobs';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private http: HttpClient
|
private http: HttpClient,
|
||||||
|
private jobCache: JobCacheService
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
loadJobs(ops: any): Observable<IJob[]> {
|
loadJobs(ops: any): Observable<IJob[]> {
|
||||||
let _ops = new HttpParams()
|
let _ops = new HttpParams()
|
||||||
.set('clientId', ops?.clientId || '')
|
.set('jpo', ops?.jobsByPilot || 'false');
|
||||||
.set('jpo', ops?.jobsByPilot || 'false')
|
|
||||||
.set('status', ops?.status || '');
|
|
||||||
|
|
||||||
if (ops?.byTime?.length === 2) {
|
if (ops?.filters != null) {
|
||||||
for (const time of ops.byTime) {
|
// Filter-submit path: all filtering is encoded in the filters param
|
||||||
if (time) {
|
_ops = _ops.set('filters', ops.filters);
|
||||||
_ops = _ops.append('byTime', time.toISOString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_ops = _ops.append('byTime', ops?.byTime[0] || '');
|
// Legacy reload path: use individual params
|
||||||
|
_ops = _ops
|
||||||
|
.set('clientId', ops?.clientId || '')
|
||||||
|
.set('status', ops?.status || '');
|
||||||
|
if (ops?.byTime?.length === 2) {
|
||||||
|
for (const time of ops.byTime) {
|
||||||
|
if (time) {
|
||||||
|
_ops = _ops.append('byTime', time.toISOString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_ops = _ops.append('byTime', ops?.byTime?.[0] || '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.http.get<IJob[]>(this.jobURL, { params: _ops });
|
const cacheKey = _ops.toString();
|
||||||
|
|
||||||
|
return this.jobCache.get(cacheKey).pipe(
|
||||||
|
switchMap(cached => {
|
||||||
|
if (cached !== null) {
|
||||||
|
return new Observable<IJob[]>(observer => {
|
||||||
|
observer.next(cached as IJob[]);
|
||||||
|
observer.complete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
|
||||||
|
tap(data => this.jobCache.put(cacheKey, data))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getJob(id: number, withItems: boolean = false, withLines?: boolean): Observable<IUIJob> {
|
getJob(id: number, withItems: boolean = false, withLines?: boolean): Observable<IUIJob> {
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
.ui-g-12.ui-sm-12.ui-md-12.ui-lg-10.ui-xl-10 {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
@ -16,6 +16,11 @@
|
|||||||
<i class="ui-icon-search"></i>
|
<i class="ui-icon-search"></i>
|
||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
|
<p-dropdown *ngIf="col.field === 'color'" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'color', 'equals')"></p-dropdown>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'desc'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export class CropListComponent extends BaseComp implements OnInit, AfterViewInit
|
|||||||
loading$ = this.store.select(fromEntity.getCropsLoading);
|
loading$ = this.store.select(fromEntity.getCropsLoading);
|
||||||
|
|
||||||
sprZoneColors: SelectItem[] = [...GC.selSprZoneColors];
|
sprZoneColors: SelectItem[] = [...GC.selSprZoneColors];
|
||||||
|
colorFilterOpts: SelectItem[] = [GC.selAll, ...GC.selSprZoneColors];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@ -17,6 +17,10 @@
|
|||||||
<i class="ui-icon-search"></i>
|
<i class="ui-icon-search"></i>
|
||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'address'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -17,6 +17,15 @@
|
|||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<p-dropdown *ngIf="col.field === 'type'" [options]="prodTypes" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'type', 'equals')"></p-dropdown>
|
<p-dropdown *ngIf="col.field === 'type'" [options]="prodTypes" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'type', 'equals')"></p-dropdown>
|
||||||
|
<p-dropdown *ngIf="col.field === 'restricted'" [options]="restrictedOpts" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'restricted', 'equals')"></p-dropdown>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'rate'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, 'rateStr', 'contains')" [value]="dt.filters['rateStr']?.value">
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'desc'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -30,6 +30,11 @@ export class ProductListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
|
|
||||||
prodTypes: SelectItem[] = [GC.selAll, ...GC.selProdTypes];
|
prodTypes: SelectItem[] = [GC.selAll, ...GC.selProdTypes];
|
||||||
prodTypes2: SelectItem[] = [...GC.selProdTypes];
|
prodTypes2: SelectItem[] = [...GC.selProdTypes];
|
||||||
|
restrictedOpts: SelectItem[] = [
|
||||||
|
{ label: globals.all, value: null },
|
||||||
|
{ label: $localize`:@@yes:Yes`, value: true },
|
||||||
|
{ label: $localize`:@@no:No`, value: false },
|
||||||
|
];
|
||||||
rateUnits: SelectItem[] = [
|
rateUnits: SelectItem[] = [
|
||||||
{ label: 'oz/ac', value: 0 },
|
{ label: 'oz/ac', value: 0 },
|
||||||
{ label: 'gal/ac', value: 1 },
|
{ label: 'gal/ac', value: 1 },
|
||||||
@ -61,7 +66,7 @@ export class ProductListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.sub$ = this.store.pipe(select(fromEntity.getAllProducts))
|
this.sub$ = this.store.pipe(select(fromEntity.getAllProducts))
|
||||||
.subscribe((items) => {
|
.subscribe((items) => {
|
||||||
this.products = items;
|
this.products = items.map(p => ({ ...p, rateStr: this.getRate(p.rate) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sub$.add(this.appActions.ofTypes([productActions.CREATE_SUCCESS, productActions.UPDATE_SUCCESS]).subscribe((action) => {
|
this.sub$.add(this.appActions.ofTypes([productActions.CREATE_SUCCESS, productActions.UPDATE_SUCCESS]).subscribe((action) => {
|
||||||
|
|||||||
@ -60,6 +60,22 @@
|
|||||||
[ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
|
[ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
|
||||||
<p-dropdown *ngIf="col.field === VEHICLE_TYPE" [options]="acTypes" [ngModel]="dt.filters[col.field]?.value"
|
<p-dropdown *ngIf="col.field === VEHICLE_TYPE" [options]="acTypes" [ngModel]="dt.filters[col.field]?.value"
|
||||||
(onChange)="dt.filter($event.value, VEHICLE_TYPE, 'equals')"></p-dropdown>
|
(onChange)="dt.filter($event.value, VEHICLE_TYPE, 'equals')"></p-dropdown>
|
||||||
|
<p-dropdown *ngIf="col.field === ACTIVE" [options]="activeOpts" [ngModel]="dt.filters[col.field]?.value"
|
||||||
|
(onChange)="dt.filter($event.value, ACTIVE, 'equals')"></p-dropdown>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === SOURCE_SYSTEM">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
|
<p-dropdown *ngIf="col.field === COLOR" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value"
|
||||||
|
(onChange)="dt.filter($event.value, COLOR, 'equals')"></p-dropdown>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === MODEL">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === TRK_ON_DATE">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { ConfirmationService, SelectItem } from 'primeng/api';
|
|||||||
import { Vehicle } from '../../models/vehicle.model';
|
import { Vehicle } from '../../models/vehicle.model';
|
||||||
import * as vehicleActions from '../../actions/vehicle.actions';
|
import * as vehicleActions from '../../actions/vehicle.actions';
|
||||||
import * as fromEntity from '../../reducers';
|
import * as fromEntity from '../../reducers';
|
||||||
import { RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
|
import { GC, RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
|
||||||
import { DateUtils, Utils } from '@app/shared/utils';
|
import { DateUtils, Utils } from '@app/shared/utils';
|
||||||
import { BaseComp } from '@app/shared/base/base.component';
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
|
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
|
||||||
@ -59,6 +59,8 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
@ViewChild('updateBtn') updateBtn: ElementRef;
|
@ViewChild('updateBtn') updateBtn: ElementRef;
|
||||||
cols: any[] = [];
|
cols: any[] = [];
|
||||||
acTypes: SelectItem[];
|
acTypes: SelectItem[];
|
||||||
|
activeOpts: SelectItem[];
|
||||||
|
colorFilterOpts: SelectItem[];
|
||||||
loading$ = this.store.select(fromEntity.getVehiclesLoading);
|
loading$ = this.store.select(fromEntity.getVehiclesLoading);
|
||||||
trkLimit: Limit;
|
trkLimit: Limit;
|
||||||
pkgLimit: Limit;
|
pkgLimit: Limit;
|
||||||
@ -141,6 +143,12 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
|
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
|
||||||
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
|
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
|
||||||
];
|
];
|
||||||
|
this.activeOpts = [
|
||||||
|
{ label: globals.all, value: null },
|
||||||
|
{ label: globals.active, value: true },
|
||||||
|
{ label: globals.notActive, value: false },
|
||||||
|
];
|
||||||
|
this.colorFilterOpts = [GC.selAll, ...GC.selSprZoneColors];
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@ -499,7 +507,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
initVehList() {
|
initVehList() {
|
||||||
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
|
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
|
||||||
map((vehicles) => {
|
map((vehicles) => {
|
||||||
this.vehicles = vehicles;
|
this.vehicles = vehicles.map(v => ({ ...v, sourceSystem: v.partnerInfo?.metadata?.partnerSystem || SourceSystem.AGNAV }));
|
||||||
this.vehSelLastUpdated = this.createVehSelections(vehicles);
|
this.vehSelLastUpdated = this.createVehSelections(vehicles);
|
||||||
this.vehiclesChanged = this.isVehSelChanged();
|
this.vehiclesChanged = this.isVehSelChanged();
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<p-table #ci [value]="costingItems" [columns]="cols" selectionMode="single" [paginator]="true" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" stateStorage="session" stateKey="costingItem-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false" [(selection)]="selectedItem">
|
<p-table #ci [value]="costingItems" [columns]="cols" selectionMode="single" [paginator]="true" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" stateStorage="session" stateKey="costingItem-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false" [(selection)]="selectedItem">
|
||||||
<ng-template pTemplate="caption">
|
<ng-template pTemplate="caption">
|
||||||
<div class="ui-g ui-g-nopad">
|
<div class="ui-g ui-g-nopad">
|
||||||
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
|
<div class="ui-g-12 ui-g-nopad" style="text-align: center">
|
||||||
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@costingItems">Costing Items</span>
|
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@costingItems">Costing Items</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -23,6 +23,11 @@
|
|||||||
<input pInputText type="text" (input)="ci.filter($event.target.value, col.field, col.filterMatchMode)" [value]="ci.filters[col.field]?.value">
|
<input pInputText type="text" (input)="ci.filter($event.target.value, col.field, col.filterMatchMode)" [value]="ci.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<p-dropdown *ngIf="col.field === 'type'" [options]="costingItemTypeOpt" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'type', 'equals')"></p-dropdown>
|
<p-dropdown *ngIf="col.field === 'type'" [options]="costingItemTypeOpt" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'type', 'equals')"></p-dropdown>
|
||||||
|
<p-dropdown *ngIf="col.field === 'unit'" [options]="unitFilterOpts" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'unit', 'equals')"></p-dropdown>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'price'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="ci.filter($event.target.value, col.field, 'contains')" [value]="ci.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -35,6 +35,17 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
costingTypes;
|
costingTypes;
|
||||||
costingItemTypeOpt;
|
costingItemTypeOpt;
|
||||||
amountUnits;
|
amountUnits;
|
||||||
|
unitFilterOpts = [
|
||||||
|
{ label: globals.all, value: null },
|
||||||
|
{ label: 'acre', value: CostingItemUnit.ACRE },
|
||||||
|
{ label: 'ha', value: CostingItemUnit.HA },
|
||||||
|
{ label: 'oz', value: CostingItemUnit.OZ },
|
||||||
|
{ label: 'gal', value: CostingItemUnit.GAL },
|
||||||
|
{ label: 'lb', value: CostingItemUnit.LB },
|
||||||
|
{ label: 'lit', value: CostingItemUnit.LIT },
|
||||||
|
{ label: 'kg', value: CostingItemUnit.KG },
|
||||||
|
{ label: 'hour', value: CostingItemUnit.HOUR },
|
||||||
|
];
|
||||||
currencyUnit;
|
currencyUnit;
|
||||||
totalCostingItems;
|
totalCostingItems;
|
||||||
isNewItem = true;
|
isNewItem = true;
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<p-table #il [value]="invoices" [columns]="cols" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" selectionMode="multiple" (onRowSelect)="onSelectInvoice($event)" (onRowUnselect)="onUnselectInvoice($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" stateStorage="session" stateKey="inv-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false" [(selection)]="selectedInvoice">
|
<p-table #il [value]="invoices" [columns]="cols" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" selectionMode="multiple" (onRowSelect)="onSelectInvoice($event)" (onRowUnselect)="onUnselectInvoice($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" stateStorage="session" stateKey="inv-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false" [(selection)]="selectedInvoice">
|
||||||
<ng-template pTemplate="caption">
|
<ng-template pTemplate="caption">
|
||||||
<div class="ui-g ui-g-nopad">
|
<div class="ui-g ui-g-nopad">
|
||||||
<div class="ui-g-6 ui-g-nopad text-left">
|
<div class="ui-g-12 ui-g-nopad text-center">
|
||||||
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@invoiceList">Invoice List</span>
|
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@invoiceList">Invoice List</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -22,6 +22,10 @@
|
|||||||
<p-calendar #odf *ngIf="col.field == 'openDate'" selectionMode="range" [(ngModel)]="openDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(openDateRange, col.field, openDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(openDateRange, col.field, openDateFilter)"></p-calendar>
|
<p-calendar #odf *ngIf="col.field == 'openDate'" selectionMode="range" [(ngModel)]="openDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(openDateRange, col.field, openDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(openDateRange, col.field, openDateFilter)"></p-calendar>
|
||||||
<p-calendar #ddf *ngIf="col.field == 'dueDate'" selectionMode="range" [(ngModel)]="dueDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(dueDateRange, col.field, dueDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(dueDateRange, col.field, dueDateFilter)"></p-calendar>
|
<p-calendar #ddf *ngIf="col.field == 'dueDate'" selectionMode="range" [(ngModel)]="dueDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(dueDateRange, col.field, dueDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(dueDateRange, col.field, dueDateFilter)"></p-calendar>
|
||||||
<p-multiSelect *ngIf="col.field === 'status'" [options]="status" [(ngModel)]="statusFilter" i18n-defaultLabel="@@all" defaultLabel="All" (onChange)="onStatusFilter($event)"></p-multiSelect>
|
<p-multiSelect *ngIf="col.field === 'status'" [options]="status" [(ngModel)]="statusFilter" i18n-defaultLabel="@@all" defaultLabel="All" (onChange)="onStatusFilter($event)"></p-multiSelect>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'totalAmount'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="onTextFilter($event, col.field, col.filterMatchMode)" [value]="il.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<div class="ui-g ui-fluid" style="max-width: 1025px;">
|
<div class="ui-g ui-fluid">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card card-w-title">
|
<div class="card card-w-title">
|
||||||
<h1 i18n="@@invoiceSettings">Invoice Settings</h1>
|
<h1 i18n="@@invoiceSettings">Invoice Settings</h1>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { toJob } from '../models/job.model';
|
|||||||
import * as jobActions from '../actions/job.actions';
|
import * as jobActions from '../actions/job.actions';
|
||||||
|
|
||||||
import { JobService } from '@app/domain/services/job.service';
|
import { JobService } from '@app/domain/services/job.service';
|
||||||
|
import { JobCacheService } from '@app/domain/services/job-cache.service';
|
||||||
import { AppMessageService } from '@app/shared/app-message.service';
|
import { AppMessageService } from '@app/shared/app-message.service';
|
||||||
|
|
||||||
import { globals } from '@app/shared/global';
|
import { globals } from '@app/shared/global';
|
||||||
@ -19,6 +20,7 @@ export class JobEffects {
|
|||||||
|
|
||||||
private readonly actions$: Actions,
|
private readonly actions$: Actions,
|
||||||
private readonly jobSvc: JobService,
|
private readonly jobSvc: JobService,
|
||||||
|
private readonly jobCache: JobCacheService,
|
||||||
private readonly msgSvc: AppMessageService,
|
private readonly msgSvc: AppMessageService,
|
||||||
private readonly gaSvc: GAService
|
private readonly gaSvc: GAService
|
||||||
) {
|
) {
|
||||||
@ -63,6 +65,7 @@ export class JobEffects {
|
|||||||
priority: 'medium' // Default priority
|
priority: 'medium' // Default priority
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.jobCache.invalidate();
|
||||||
return new jobActions.CreateSuccess(job);
|
return new jobActions.CreateSuccess(job);
|
||||||
}),
|
}),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
@ -106,6 +109,7 @@ export class JobEffects {
|
|||||||
save_method: 'manual'
|
save_method: 'manual'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.jobCache.invalidate();
|
||||||
return new jobActions.UpdateSuccess(updatedJob)
|
return new jobActions.UpdateSuccess(updatedJob)
|
||||||
}),
|
}),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
@ -139,6 +143,7 @@ export class JobEffects {
|
|||||||
Math.floor((new Date().getTime() - new Date(payload.createdAt).getTime()) / (1000 * 60 * 60)) : 0
|
Math.floor((new Date().getTime() - new Date(payload.createdAt).getTime()) / (1000 * 60 * 60)) : 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.jobCache.invalidate();
|
||||||
return new jobActions.DeleteSuccess(payload)
|
return new jobActions.DeleteSuccess(payload)
|
||||||
}),
|
}),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
|
|||||||
@ -10,12 +10,6 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host ::ng-deep .ui-calendar input,
|
:host ::ng-deep .ui-fluid .ui-calendar {
|
||||||
:host ::ng-deep .ui-calendar .ui-datepicker-trigger {
|
width: 100%;
|
||||||
opacity: 0;
|
|
||||||
height: 1px;
|
|
||||||
width: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
}
|
||||||
@ -1,7 +1,13 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card clearfix">
|
<div class="card clearfix">
|
||||||
<p-table #dt [value]="jobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()"
|
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||||
|
<p-accordionTab i18n-header="@@searchJobs" header="Search Jobs" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
|
||||||
|
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
|
||||||
|
<agm-dynamic-filter [filterDefinitions]="jobFilterDefinitions" [locale]="locale" [defaultFilters]="defaultDynamicFilters" stateKey="job-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
|
||||||
|
</p-accordionTab>
|
||||||
|
</p-accordion>
|
||||||
|
<p-table #dt [value]="filteredJobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()"
|
||||||
(onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" (onRowSelect)="onRowSelect($event)"
|
(onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" (onRowSelect)="onRowSelect($event)"
|
||||||
(onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5"
|
(onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5"
|
||||||
[rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" [(selection)]="currentJob" stateStorage="session"
|
[rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" [(selection)]="currentJob" stateStorage="session"
|
||||||
@ -30,10 +36,19 @@
|
|||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
[value]="dt.filters[col.field]?.value">
|
[value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label"
|
<p-dropdown #cl *ngIf="col.field === 'client.name' && !filterClientLocked" name="clients" [options]="clients" optionLabel="label"
|
||||||
[ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
|
[ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
|
||||||
|
<span *ngIf="col.field === 'client.name' && filterClientLocked">{{ currClient.label }}</span>
|
||||||
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter"
|
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter"
|
||||||
(onChange)="handleStatusFilter($event.value)"></p-dropdown>
|
(onChange)="handleStatusFilter($event.value)"></p-dropdown>
|
||||||
|
<p-calendar *ngIf="col.field === 'startDate'" [(ngModel)]="startDateFilter" [locale]="locale"
|
||||||
|
[dateFormat]="locale.dateFormat" [showButtonBar]="true" [showIcon]="true" appendTo="body"
|
||||||
|
(onSelect)="onDateFilter($event, 'startDate')" (onClearClick)="onDateFilter(null, 'startDate')"
|
||||||
|
[style]="{'width':'100%'}" i18n-placeholder="@@filterDate" placeholder="Filter..."></p-calendar>
|
||||||
|
<p-calendar *ngIf="col.field === 'endDate'" [(ngModel)]="endDateFilter" [locale]="locale"
|
||||||
|
[dateFormat]="locale.dateFormat" [showButtonBar]="true" [showIcon]="true" appendTo="body"
|
||||||
|
(onSelect)="onDateFilter($event, 'endDate')" (onClearClick)="onDateFilter(null, 'endDate')"
|
||||||
|
[style]="{'width':'100%'}" i18n-placeholder="@@filterDate" placeholder="Filter..."></p-calendar>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -87,27 +102,7 @@
|
|||||||
|
|
||||||
<ng-template #dropdowns>
|
<ng-template #dropdowns>
|
||||||
<div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad">
|
<div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad">
|
||||||
<div class="ui-g-8 ui-lg-8 ui-md-12 ui-sm-12 inline-flex-end">
|
<div class="ui-g-12 inline-flex-end">
|
||||||
<div class="ui-g">
|
|
||||||
<div class="ui-g-12">
|
|
||||||
<span i18n="@@filtJobsByCreatedDate">Filter Jobs By Created Date</span>
|
|
||||||
<p-calendar #calendar [(ngModel)]="selCalDate" selectionMode="range" [readonlyInput]="true"
|
|
||||||
[showButtonBar]="true" [showIcon]="true" (onClose)="onCalClose()"></p-calendar>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p-dropdown [style]="dropdownStyle" [options]="dateOptions" [(ngModel)]="selDate"
|
|
||||||
(onChange)="onDropdownChange($event)">
|
|
||||||
<ng-template let-item pTemplate="item">
|
|
||||||
<div class="ui-g">
|
|
||||||
<div [ngClass]="isShowXBtn(item) ? 'ui-g-8' : 'ui-g-12'" class="ui-g-nopad">{{ item.label }}</div>
|
|
||||||
<div *ngIf="isShowXBtn(item)" class="ui-g-4 ui-g-nopad" style="text-align: center;"><button
|
|
||||||
style="border: unset; background: none; cursor: pointer;" class="pi pi-times"
|
|
||||||
(click)="onCalClick()"></button></div>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
</p-dropdown>
|
|
||||||
</div>
|
|
||||||
<div class="ui-g-4 ui-lg-4 ui-md-12 ui-sm-12 inline-flex-end">
|
|
||||||
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
|
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
|
||||||
(onChange)="reloadChanged($event.value)">
|
(onChange)="reloadChanged($event.value)">
|
||||||
</p-dropdown>
|
</p-dropdown>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Subscription, interval } from 'rxjs';
|
|||||||
import { SelectItem } from 'primeng/api';
|
import { SelectItem } from 'primeng/api';
|
||||||
import { Dropdown } from 'primeng/dropdown';
|
import { Dropdown } from 'primeng/dropdown';
|
||||||
import { Table } from 'primeng/table';
|
import { Table } from 'primeng/table';
|
||||||
|
import { FilterUtils } from 'primeng/utils';
|
||||||
|
|
||||||
import { IUIJob } from '../models/job.model';
|
import { IUIJob } from '../models/job.model';
|
||||||
import * as jobActions from '../actions/job.actions';
|
import * as jobActions from '../actions/job.actions';
|
||||||
@ -26,8 +27,9 @@ import { Acre } from '@app/domain/models/subscription.model';
|
|||||||
import { SUB, SubTexts, SubType } from '@app/profile/common';
|
import { SUB, SubTexts, SubType } from '@app/profile/common';
|
||||||
import { InvoiceService } from '@app/domain/services/invoice.service';
|
import { InvoiceService } from '@app/domain/services/invoice.service';
|
||||||
import { RestoreTableState } from '@app/shared/restore-table-state';
|
import { RestoreTableState } from '@app/shared/restore-table-state';
|
||||||
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
|
||||||
import { GAService } from '@app/shared/ga.service';
|
import { GAService } from '@app/shared/ga.service';
|
||||||
|
import { JobCacheService } from '@app/domain/services/job-cache.service';
|
||||||
|
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -38,23 +40,46 @@ import { GAService } from '@app/shared/ga.service';
|
|||||||
export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
|
export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
|
||||||
globals = globals;
|
globals = globals;
|
||||||
readonly dropdownStyle = { 'min-width': '170px', 'color': 'black' };
|
readonly dropdownStyle = { 'min-width': '170px', 'color': 'black' };
|
||||||
readonly customeDate = 'customDate';
|
|
||||||
|
|
||||||
jobs: Array<IUIJob> = [];
|
jobs: Array<IUIJob> = [];
|
||||||
|
filteredJobs: Array<IUIJob> = [];
|
||||||
currentJob: IUIJob;
|
currentJob: IUIJob;
|
||||||
currClient: SelectItem;
|
currClient: SelectItem;
|
||||||
|
filterClientLocked = false;
|
||||||
clients: SelectItem[];
|
clients: SelectItem[];
|
||||||
defaultInvoiceSetting;
|
defaultInvoiceSetting;
|
||||||
|
|
||||||
|
private currentByTime: string[] | undefined;
|
||||||
|
private lastFiltersQuery: Record<string, any> | undefined;
|
||||||
|
|
||||||
|
jobFilterDefinitions: FilterDefinition[] = [];
|
||||||
|
readonly defaultDynamicFilters = [
|
||||||
|
...(!this.isClientUser ? [{ key: 'client', value: null }] : []),
|
||||||
|
{ key: 'createdAt', value: '1m' }
|
||||||
|
];
|
||||||
|
|
||||||
@ViewChild('dt') public dt: Table;
|
@ViewChild('dt') public dt: Table;
|
||||||
@ViewChild('cl') public cl: Dropdown;
|
private _cl: Dropdown;
|
||||||
@ViewChild('calendar') calendar: any;
|
@ViewChild('cl') set cl(dropdown: Dropdown) {
|
||||||
|
this._cl = dropdown;
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.registerOnChange((newVal) => {
|
||||||
|
this.currClient = newVal;
|
||||||
|
this.filteredJobs = newVal.value
|
||||||
|
? this.jobs.filter(j => j.client?._id === newVal.value)
|
||||||
|
: this.jobs;
|
||||||
|
this.dt.first = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rows1Page = [10, 15, 30, 60, 100];
|
rows1Page = [10, 15, 30, 60, 100];
|
||||||
cols: any[];
|
cols: any[];
|
||||||
|
|
||||||
status: SelectItem[] = [GC.selAll, ...GC.selJobStatuses];
|
status: SelectItem[] = [GC.selAll, ...GC.selJobStatuses];
|
||||||
statusFilter;
|
statusFilter;
|
||||||
|
startDateFilter: Date;
|
||||||
|
endDateFilter: Date;
|
||||||
reloadOps: SelectItem[];
|
reloadOps: SelectItem[];
|
||||||
reloadBy = 0;
|
reloadBy = 0;
|
||||||
reload$: Subscription;
|
reload$: Subscription;
|
||||||
@ -63,13 +88,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
totalJobs;
|
totalJobs;
|
||||||
|
|
||||||
acre: Acre;
|
acre: Acre;
|
||||||
dateOptions: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}[];
|
|
||||||
selDate: string;
|
|
||||||
|
|
||||||
selCalDate: [Date, Date];
|
searchAccordionOpen = sessionStorage.getItem('job-list-accordion') === 'true';
|
||||||
|
|
||||||
get canWrite(): boolean {
|
get canWrite(): boolean {
|
||||||
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]);
|
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]);
|
||||||
@ -85,8 +105,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
private readonly datePipe: DatePipe,
|
private readonly datePipe: DatePipe,
|
||||||
private readonly invoiceSvc: InvoiceService,
|
private readonly invoiceSvc: InvoiceService,
|
||||||
private readonly restoreTableSvc: RestoreTableState,
|
private readonly restoreTableSvc: RestoreTableState,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly gaService: GAService,
|
||||||
private readonly gaService: GAService
|
private readonly jobCache: JobCacheService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.currClient = ({ label: globals.all, value: null });
|
this.currClient = ({ label: globals.all, value: null });
|
||||||
@ -130,8 +150,26 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
this.showStatusPlus = !this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR]);
|
this.showStatusPlus = !this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR]);
|
||||||
this.defaultInvoiceSetting = this.invoiceSvc.defaultSetting;
|
this.defaultInvoiceSetting = this.invoiceSvc.defaultSetting;
|
||||||
|
|
||||||
this.dateOptions = this.subscriptionService.getDateOptions();
|
(FilterUtils as any)['dateIs'] = (value: any, filter: any): boolean => {
|
||||||
this.dateOptions.push({ label: $localize`:@@customDate:Custom Date`, value: this.customeDate });
|
if (!filter) { return true; }
|
||||||
|
if (!value) { return false; }
|
||||||
|
const valDate = new Date(value);
|
||||||
|
const filterDate = new Date(filter);
|
||||||
|
return valDate.getFullYear() === filterDate.getFullYear()
|
||||||
|
&& valDate.getMonth() === filterDate.getMonth()
|
||||||
|
&& valDate.getDate() === filterDate.getDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.jobFilterDefinitions = [
|
||||||
|
...(!this.isClientUser ? [{ key: 'client', label: $localize`:@@client:Client`, dataType: 'select' as const, options: [] }] : []),
|
||||||
|
{ key: '_id', label: $localize`:@@id:Id` + ' ' + globals.num, dataType: 'text' as const },
|
||||||
|
{ key: 'orderNumber', label: $localize`:@@order:Order` + ' ' + globals.num, dataType: 'text' as const },
|
||||||
|
{ key: 'name', label: globals.name, dataType: 'text' as const },
|
||||||
|
{ key: 'startDate', label: $localize`:@@startDate:Start Date`, dataType: 'date' as const },
|
||||||
|
{ key: 'endDate', label: $localize`:@@endDate:End Date`, dataType: 'date' as const },
|
||||||
|
{ key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date-preset' as const },
|
||||||
|
{ key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi' as const, options: GC.selJobStatuses },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@ -144,6 +182,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
this.clients = clients.map(it => ({ value: it._id, label: it.name }));
|
this.clients = clients.map(it => ({ value: it._id, label: it.name }));
|
||||||
if (!this.isClientUser) {
|
if (!this.isClientUser) {
|
||||||
this.clients.unshift(({ label: globals.all, value: null }));
|
this.clients.unshift(({ label: globals.all, value: null }));
|
||||||
|
const clientDef = this.jobFilterDefinitions.find(f => f.key === 'client');
|
||||||
|
if (clientDef) { clientDef.options = this.clients; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.sub$.add(this.store.pipe(select(fromClients.getSelectedClient)).subscribe(client => {
|
this.sub$.add(this.store.pipe(select(fromClients.getSelectedClient)).subscribe(client => {
|
||||||
@ -156,6 +196,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
}
|
}
|
||||||
})); this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
|
})); this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
|
||||||
this.jobs = jobs;
|
this.jobs = jobs;
|
||||||
|
this.filteredJobs = jobs;
|
||||||
}));
|
}));
|
||||||
this.sub$.add(this.store.pipe(select(fromJobs.getSelectedJob)).subscribe((job) => {
|
this.sub$.add(this.store.pipe(select(fromJobs.getSelectedJob)).subscribe((job) => {
|
||||||
this.currentJob = job;
|
this.currentJob = job;
|
||||||
@ -190,32 +231,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
const invoiced = listFilter.filters.invoiceStatus?.value;
|
const invoiced = listFilter.filters.invoiceStatus?.value;
|
||||||
this.restoreStatusState(status, invoiced);
|
this.restoreStatusState(status, invoiced);
|
||||||
}
|
}
|
||||||
const storedDateSelection = sessionStorage.getItem('jobListSelDate');
|
|
||||||
if (storedDateSelection) {
|
|
||||||
const parsedDateSelection = JSON.parse(storedDateSelection);
|
|
||||||
if (parsedDateSelection.selDate) {
|
|
||||||
this.selDate = parsedDateSelection.selDate;
|
|
||||||
} else {
|
|
||||||
if (parsedDateSelection.selCalDate) {
|
|
||||||
if (parsedDateSelection.selCalDate[0] && parsedDateSelection.selCalDate[1]) {
|
|
||||||
this.selCalDate = [new Date(parsedDateSelection.selCalDate[0]), new Date(parsedDateSelection.selCalDate[1])];
|
|
||||||
} else {
|
|
||||||
this.selCalDate = [new Date(parsedDateSelection.selCalDate[0]), null];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.selDate = this.selCalDate ? this.customeDate : this.dateOptions[0].value;
|
|
||||||
this.setCustomDateLabel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.cl) {
|
|
||||||
this.cl.registerOnChange((newVal) => {
|
|
||||||
this.store.dispatch(new clientActions.Select(<Client>({ _id: newVal.value })));
|
|
||||||
this.fetchJobsByClient(this.currClient.value);
|
|
||||||
this.dt.first = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.fetchJobsByClient(this.currClient.value);
|
|
||||||
if (this.dt.rows >= this.dt.totalRecords) {
|
if (this.dt.rows >= this.dt.totalRecords) {
|
||||||
this.dt.first = 0;
|
this.dt.first = 0;
|
||||||
}
|
}
|
||||||
@ -258,22 +274,28 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
[jobListStatus.INVOICED]: jobInvoiceStatus.INVOICED
|
[jobListStatus.INVOICED]: jobInvoiceStatus.INVOICED
|
||||||
};
|
};
|
||||||
|
|
||||||
const byTime =
|
|
||||||
this.selDate
|
|
||||||
? this.selDate == this.customeDate
|
|
||||||
? this.selCalDate
|
|
||||||
: [this.selDate]
|
|
||||||
: [this.dateOptions[0].value];
|
|
||||||
|
|
||||||
const statusValue = statusMap[this.statusFilter] ?? jobListStatus.ALL;
|
const statusValue = statusMap[this.statusFilter] ?? jobListStatus.ALL;
|
||||||
this.store.dispatch(new jobActions.Fetch({
|
this.store.dispatch(new jobActions.Fetch({
|
||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||||
byTime,
|
byTime: this.currentByTime,
|
||||||
status: statusValue
|
status: statusValue
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCreatedDateChanged(byTime: string[]): void {
|
||||||
|
this.currentByTime = byTime;
|
||||||
|
this.reloadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateFilter(value: Date, field: string) {
|
||||||
|
this.dt.filter(value, field, 'dateIs');
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccordionToggle(expanded: boolean) {
|
||||||
|
sessionStorage.setItem('job-list-accordion', String(expanded));
|
||||||
|
}
|
||||||
|
|
||||||
restoreTableFirst() {
|
restoreTableFirst() {
|
||||||
this.restoreTableSvc.restoreTableFirst(this.dt);
|
this.restoreTableSvc.restoreTableFirst(this.dt);
|
||||||
}
|
}
|
||||||
@ -364,8 +386,16 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
|
|
||||||
reloadJobs() {
|
reloadJobs() {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
this.jobCache.invalidate();
|
||||||
|
|
||||||
this.fetchJobsByClient(this.currClient && this.currClient.value);
|
if (this.lastFiltersQuery) {
|
||||||
|
this.store.dispatch(new jobActions.Fetch({
|
||||||
|
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||||
|
filters: JSON.stringify(this.lastFiltersQuery)
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
this.fetchJobsByClient(this.currClient && this.currClient.value);
|
||||||
|
}
|
||||||
|
|
||||||
// Track job list reload
|
// Track job list reload
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -421,6 +451,27 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
this.router.navigate(['/clients']);
|
this.router.navigate(['/clients']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFiltersSubmit(event: FilterChangeEvent) {
|
||||||
|
const q = { ...event.query };
|
||||||
|
// Ensure createdAt always has a value so the server always applies a date range.
|
||||||
|
// Default to 'Past 1 Month' if the user has not added a Created Date filter.
|
||||||
|
if (!q.createdAt) {
|
||||||
|
q.createdAt = { value: '1m', operator: 'and', valueOperator: 'exact', dataType: 'date-preset' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the table's client dropdown to match the client selected in the search filters.
|
||||||
|
const clientId = q.client?.value ?? null;
|
||||||
|
const matchedClient = this.clients?.find(c => c.value === clientId);
|
||||||
|
this.currClient = matchedClient || { label: globals.all, value: null };
|
||||||
|
this.filterClientLocked = !!clientId;
|
||||||
|
|
||||||
|
this.lastFiltersQuery = q;
|
||||||
|
this.store.dispatch(new jobActions.Fetch({
|
||||||
|
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||||
|
filters: JSON.stringify(q)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
getUsers(byUsers) {
|
getUsers(byUsers) {
|
||||||
if (!byUsers || !Array.isArray(byUsers) || byUsers.length === 0) {
|
if (!byUsers || !Array.isArray(byUsers) || byUsers.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
@ -487,96 +538,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setJobListSelDate(dateSelection): void {
|
|
||||||
sessionStorage.setItem('jobListSelDate', JSON.stringify(dateSelection));
|
|
||||||
}
|
|
||||||
|
|
||||||
private setCustomDateLabel(): void {
|
|
||||||
const dateFormat = this.locale.dateFormat.replace(/(^|\/)mm(\/|$)/g, '$1MM$2');
|
|
||||||
|
|
||||||
if (!this.selCalDate) {
|
|
||||||
this.dateOptions.find(it => it.value === this.customeDate).label = $localize`:@@customDate:Custom Date`;
|
|
||||||
} else if (!this.selCalDate[1]) {
|
|
||||||
this.dateOptions.find(it => it.value === this.customeDate).label =
|
|
||||||
`${this.datePipe.transform(this.selCalDate[0], dateFormat)}`;
|
|
||||||
} else {
|
|
||||||
this.dateOptions.find(it => it.value === this.customeDate).label =
|
|
||||||
`${this.datePipe.transform(this.selCalDate[0], dateFormat)} - ${this.datePipe.transform(this.selCalDate[1], dateFormat)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onDropdownChange(evt): void {
|
|
||||||
const previousCount = this.jobs?.length || 0;
|
|
||||||
|
|
||||||
if (evt.value === this.customeDate) {
|
|
||||||
setTimeout(() => this.showCal());
|
|
||||||
} else {
|
|
||||||
this.setJobListSelDate({ selDate: evt.value, selCalDate: null });
|
|
||||||
this.reloadJobs();
|
|
||||||
|
|
||||||
// Track date filter usage
|
|
||||||
setTimeout(() => {
|
|
||||||
const currentCount = this.jobs?.length || 0;
|
|
||||||
this.gaService.trackJobListFiltered({
|
|
||||||
user_id: this.authSvc.user?._id || 'anonymous',
|
|
||||||
platform: 'web',
|
|
||||||
filter_type: 'date',
|
|
||||||
filter_value: evt.value,
|
|
||||||
results_before: previousCount,
|
|
||||||
results_after: currentCount,
|
|
||||||
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
|
|
||||||
date_filter_type: this.getDateFilterType(evt.value)
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onCalClose(): void {
|
|
||||||
const previousCount = this.jobs?.length || 0;
|
|
||||||
|
|
||||||
this.setCustomDateLabel();
|
|
||||||
if (this.selCalDate) {
|
|
||||||
this.setJobListSelDate({ selDate: null, selCalDate: this.selCalDate });
|
|
||||||
} else {
|
|
||||||
this.selDate = this.dateOptions[0].value;
|
|
||||||
this.setJobListSelDate({ selDate: this.selDate, selCalDate: null });
|
|
||||||
}
|
|
||||||
this.reloadJobs();
|
|
||||||
|
|
||||||
// Track custom date filter usage
|
|
||||||
if (this.selCalDate) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const currentCount = this.jobs?.length || 0;
|
|
||||||
this.gaService.trackJobListFiltered({
|
|
||||||
user_id: this.authSvc.user?._id || 'anonymous',
|
|
||||||
platform: 'web',
|
|
||||||
filter_type: 'date',
|
|
||||||
filter_value: 'custom_date_range',
|
|
||||||
results_before: previousCount,
|
|
||||||
results_after: currentCount,
|
|
||||||
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
|
|
||||||
date_filter_type: 'custom',
|
|
||||||
custom_date_range: [
|
|
||||||
this.selCalDate[0]?.toISOString().split('T')[0],
|
|
||||||
this.selCalDate[1]?.toISOString().split('T')[0]
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onCalClick() {
|
|
||||||
setTimeout(() => this.showCal());
|
|
||||||
}
|
|
||||||
|
|
||||||
showCal() {
|
|
||||||
this.calendar?.el.nativeElement.querySelector('button')?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
isShowXBtn(item) {
|
|
||||||
return item?.value == this.customeDate && this.selDate == this.customeDate
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to count active filters
|
// Helper method to count active filters
|
||||||
private getActiveFilterCount(): number {
|
private getActiveFilterCount(): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@ -592,7 +553,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check date filter
|
// Check date filter
|
||||||
if (this.selDate && this.selDate !== this.dateOptions[0]?.value) {
|
if (this.currentByTime && this.currentByTime.length > 0) {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -609,16 +570,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to determine date filter type
|
|
||||||
private getDateFilterType(value: string): 'today' | 'week' | 'month' | 'quarter' | 'custom' {
|
|
||||||
if (value === this.customeDate) return 'custom';
|
|
||||||
if (value?.includes('today')) return 'today';
|
|
||||||
if (value?.includes('week')) return 'week';
|
|
||||||
if (value?.includes('month')) return 'month';
|
|
||||||
if (value?.includes('quarter')) return 'quarter';
|
|
||||||
return 'custom';
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
super.ngOnDestroy();
|
super.ngOnDestroy();
|
||||||
if (this.reload$) {
|
if (this.reload$) {
|
||||||
|
|||||||
@ -23,11 +23,14 @@ import { TooltipModule } from 'primeng/tooltip';
|
|||||||
import { TabViewModule } from 'primeng/tabview';
|
import { TabViewModule } from 'primeng/tabview';
|
||||||
import { SliderModule } from 'primeng/slider';
|
import { SliderModule } from 'primeng/slider';
|
||||||
import { OrderListModule } from 'primeng/orderlist';
|
import { OrderListModule } from 'primeng/orderlist';
|
||||||
|
import { AccordionModule } from 'primeng/accordion';
|
||||||
|
|
||||||
import { StoreModule } from '@ngrx/store';
|
import { StoreModule } from '@ngrx/store';
|
||||||
import { EffectsModule } from '@ngrx/effects';
|
import { EffectsModule } from '@ngrx/effects';
|
||||||
import * as fromJobs from './reducers/jobs.reducer';
|
import * as fromJobs from './reducers/jobs.reducer';
|
||||||
import { JobEffects } from './effects/job.effects';
|
import { JobEffects } from './effects/job.effects';
|
||||||
|
import * as fromClients from '../client/reducers/clients.reducer';
|
||||||
|
import { ClientEffects } from '../client/effects/client.effects';
|
||||||
|
|
||||||
import { JobMgtComponent } from './job-mgt.component';
|
import { JobMgtComponent } from './job-mgt.component';
|
||||||
import { AppSharedModule } from '../shared/app-shared.module';
|
import { AppSharedModule } from '../shared/app-shared.module';
|
||||||
@ -44,12 +47,13 @@ import { InvoicesModule } from '@app/invoices/invoices.module';
|
|||||||
LeafletModule,
|
LeafletModule,
|
||||||
PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule,
|
PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule,
|
||||||
CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule,
|
CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule,
|
||||||
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule,
|
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule, AccordionModule,
|
||||||
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule,
|
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule,
|
||||||
|
|
||||||
JobsRoutingModule,
|
JobsRoutingModule,
|
||||||
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
|
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
|
||||||
EffectsModule.forFeature([JobEffects]), InvoicesModule,
|
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
|
||||||
|
EffectsModule.forFeature([JobEffects, ClientEffects]), InvoicesModule,
|
||||||
],
|
],
|
||||||
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
|
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
|
||||||
providers: [DatePipe],
|
providers: [DatePipe],
|
||||||
|
|||||||
@ -23,6 +23,10 @@
|
|||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<p-dropdown *ngIf="col.field === 'active'" [options]="statuses" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
<p-dropdown *ngIf="col.field === 'active'" [options]="statuses" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||||
|
<div class="input-with-icon" *ngIf="col.field === 'createdAt'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
|
||||||
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
*:focus {
|
*:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-g-12.ui-lg-10.ui-xl-8 {
|
||||||
|
min-width: 19rem;
|
||||||
|
width:100vw;
|
||||||
|
}
|
||||||
@ -1,22 +1,19 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12 ui-lg-10 ui-xl-8" style="margin: auto;">
|
<div class="ui-g-12 ui-lg-10 ui-xl-8">
|
||||||
<div class="ui-g" style="padding: 1em;">
|
<div class="ui-g">
|
||||||
<h1 style="margin-bottom: 1em;" i18n="@@billingAddresses">Billing Addresses</h1>
|
|
||||||
<div class="ui-g-12 card in-card-pad">
|
<div class="ui-g-12 card in-card-pad">
|
||||||
<p class="large-font align-vertical" i18n="@@selBillingAddress">Select a billing address</p>
|
<h1 class="large-font align-vertical" i18n="@@selBillingAddress" style="margin-bottom:1rem;">Billing Addresses</h1>
|
||||||
|
<hr style="width: 100%;margin-bottom:1rem;" />
|
||||||
|
|
||||||
<div class="ui-g-12 card in-card-pad">
|
<ng-container *ngIf="user.addresses?.length > 0">
|
||||||
<ng-container *ngIf="user.addresses?.length > 0">
|
<ng-container *ngTemplateOutlet="header"></ng-container>
|
||||||
<ng-container *ngTemplateOutlet="header"></ng-container>
|
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
<span class="ui-message ui-messages-error" style="width: 100%; font-size: 1em;">{{error}}</span>
|
||||||
<button type="button" pButton icon="ui-icon-plus" i18n-label="@@addAdr" label="Add Address" (click)="add()"></button>
|
|
||||||
<span class="ui-message ui-messages-error" style="width: 100%; font-size: 1em;">{{error}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr style="width: 100%;" />
|
<hr style="width: 100%;" />
|
||||||
|
|
||||||
<div class="ui-g-12" style="text-align: right;">
|
<div class="ui-g-12">
|
||||||
<ng-container *ngTemplateOutlet="btn"></ng-container>
|
<ng-container *ngTemplateOutlet="btn"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -26,7 +23,7 @@
|
|||||||
|
|
||||||
<ng-template #header>
|
<ng-template #header>
|
||||||
<div class="ui-g ui-g-nopad" style="justify-content: space-around;">
|
<div class="ui-g ui-g-nopad" style="justify-content: space-around;">
|
||||||
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@address">Address</ng-container></strong></div>
|
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@address">Select a billing address</ng-container></strong></div>
|
||||||
<div class="ui-g-2 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@name">Name</ng-container></strong></div>
|
<div class="ui-g-2 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@name">Name</ng-container></strong></div>
|
||||||
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@cityStateZip">City, State, Zip/Postal Code</ng-container></strong></div>
|
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@cityStateZip">City, State, Zip/Postal Code</ng-container></strong></div>
|
||||||
</div>
|
</div>
|
||||||
@ -55,9 +52,8 @@
|
|||||||
|
|
||||||
<ng-template #btn>
|
<ng-template #btn>
|
||||||
<button pButton type="button" i18n-label="@@back" label="Back" class="inline-space" (click)="gotoMySubs()"></button>
|
<button pButton type="button" i18n-label="@@back" label="Back" class="inline-space" (click)="gotoMySubs()"></button>
|
||||||
<ng-container *ngIf="user.addresses?.length > 1">
|
<button type="button" pButton icon="ui-icon-plus" i18n-label="@@addAdr" label="Add Address" (click)="add()"></button>
|
||||||
<button pButton type="button" [disabled]="!selectedAddress || selectedAddress.isBilling" [label]="SubTexts.labelChngBilAddr" (click)="changeBilAdr(selectedAddress)"></button>
|
<button *ngIf="user.addresses?.length > 1" style="margin-left: 0.5rem;" pButton type="button" [disabled]="!selectedAddress || selectedAddress.isBilling" [label]="SubTexts.labelChngBilAddr" (click)="changeBilAdr(selectedAddress)"></button>
|
||||||
</ng-container>
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<p-dialog [(visible)]="displayAddressDialog" [style]="{'width': '600px'}" [contentStyle]="{'overflow':'visible'}" resizable="false" modal="true">
|
<p-dialog [(visible)]="displayAddressDialog" [style]="{'width': '600px'}" [contentStyle]="{'overflow':'visible'}" resizable="false" modal="true">
|
||||||
|
|||||||
@ -1,61 +1,67 @@
|
|||||||
<ng-container *ngIf="isCompLoaded(); else err">
|
<ng-container *ngIf="isCompLoaded(); else err">
|
||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12 ui-md-11 ui-lg-10 ui-xl-8" style="margin: auto;;min-width: 564px">
|
<div class="ui-g-12">
|
||||||
<div class="ui-g">
|
<div class="card clearfix">
|
||||||
<h1 style="margin-bottom: 1em;" i18n="@@pmtHist">Payment history</h1>
|
<div class="ui-g">
|
||||||
<div class="ui-g ui-g-12">
|
<div class="ui-g-12 ui-md-11 ui-lg-10 ui-xl-8" style="margin: auto;">
|
||||||
<div class="ui-g-12">
|
|
||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-8"><ng-container i18n="@@pmtHistMsg">If you recently made a payment, please allow 24 hours for the payment to appear in the history.</ng-container></div>
|
<h1 style="margin-bottom: 1em;" i18n="@@pmtHist">Payment history</h1>
|
||||||
<div class="ui-g-4" style="display: flex; justify-content: end;">
|
<div class="ui-g ui-g-12">
|
||||||
<p-dropdown [options]="options" [(ngModel)]="optKey" (onChange)="onDateChange($event)"></p-dropdown>
|
<div class="ui-g-12">
|
||||||
|
<div class="ui-g">
|
||||||
|
<div class="ui-g-8"><ng-container i18n="@@pmtHistMsg">If you recently made a payment, please allow 24 hours for the payment to appear in the history.</ng-container></div>
|
||||||
|
<div class="ui-g-4" style="display: flex; justify-content: end;">
|
||||||
|
<p-dropdown [options]="options" [(ngModel)]="optKey" (onChange)="onDateChange($event)"></p-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-g-12">
|
||||||
|
<p-table (sortFunction)="customSort($event)" [customSort]="true" [value]="payments" [columns]="cols" [paginator]="true" [responsive]="true" [rows]="10" [rowsPerPageOptions]="[5,10,20]" [sortField]="date" sortOrder="-1">
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<tr>
|
||||||
|
<th [pSortableColumn]="col.field" class="pm-history-header" *ngFor="let col of cols" [width]="col.width">
|
||||||
|
{{col.header}}
|
||||||
|
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
||||||
|
<tr>
|
||||||
|
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||||
|
<span class="ui-column-title">{{col.header}}</span>
|
||||||
|
<span *ngSwitchCase="date">{{rowData[col.field] | tsToDate: lang}}</span>
|
||||||
|
|
||||||
|
<span *ngSwitchCase="TYPE">
|
||||||
|
<span *ngIf="rowData.object === InvType.INVOICE" i18n="@@bill">Bill</span>
|
||||||
|
<span *ngIf="rowData.object === InvType.CHARGE" i18n="@@refund">Refund</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span *ngSwitchCase="AMT_DUE">
|
||||||
|
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
||||||
|
{{rowData.amount_due | usCurrency}}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
||||||
|
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
||||||
|
</ng-container>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span *ngSwitchCase="AMT_PAID">
|
||||||
|
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
||||||
|
{{rowData.amount_paid | usCurrency}}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
||||||
|
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
||||||
|
</ng-container>
|
||||||
|
</span>
|
||||||
|
<span *ngSwitchCase="ACTIONS"><button (click)="gotoPaymentDetail(rowData)" pButton icon="ui-icon-zoom-in"></button></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui-g-12">
|
|
||||||
<p-table (sortFunction)="customSort($event)" [customSort]="true" [value]="payments" [columns]="cols" [paginator]="true" [responsive]="true" [rows]="10" [rowsPerPageOptions]="[5,10,20]" [sortField]="date" sortOrder="-1">
|
|
||||||
<ng-template pTemplate="header">
|
|
||||||
<tr>
|
|
||||||
<th [pSortableColumn]="col.field" class="pm-history-header" *ngFor="let col of cols" [width]="col.width">
|
|
||||||
{{col.header}}
|
|
||||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
|
||||||
<tr>
|
|
||||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
|
||||||
<span class="ui-column-title">{{col.header}}</span>
|
|
||||||
<span *ngSwitchCase="date">{{rowData[col.field] | tsToDate: lang}}</span>
|
|
||||||
|
|
||||||
<span *ngSwitchCase="TYPE">
|
|
||||||
<span *ngIf="rowData.object === InvType.INVOICE" i18n="@@bill">Bill</span>
|
|
||||||
<span *ngIf="rowData.object === InvType.CHARGE" i18n="@@refund">Refund</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span *ngSwitchCase="AMT_DUE">
|
|
||||||
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
|
||||||
{{rowData.amount_due | usCurrency}}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
|
||||||
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
|
||||||
</ng-container>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span *ngSwitchCase="AMT_PAID">
|
|
||||||
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
|
||||||
{{rowData.amount_paid | usCurrency}}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
|
||||||
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
|
||||||
</ng-container>
|
|
||||||
</span>
|
|
||||||
<span *ngSwitchCase="ACTIONS"><button (click)="gotoPaymentDetail(rowData)" pButton icon="ui-icon-zoom-in"></button></span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
</p-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<div class="ui-g" style="max-width: 1025px">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card card-w-title">
|
<div class="card card-w-title">
|
||||||
<h1>{{ isApplicator ? globals.applProfile : globals.userProfile }}</h1>
|
<h1>{{ isApplicator ? globals.applProfile : globals.userProfile }}</h1>
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
import { createAction, props } from '@ngrx/store';
|
||||||
|
import { ApiKey, CreateApiKeyRequest, CreateApiKeyResponse } from '../api-keys/models/api-key.model';
|
||||||
|
|
||||||
|
export const loadApiKeys = createAction(
|
||||||
|
'[ApiKey] Load Keys',
|
||||||
|
props<{ ownerId?: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const loadApiKeysSuccess = createAction(
|
||||||
|
'[ApiKey] Load Keys Success',
|
||||||
|
props<{ keys: ApiKey[] }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const loadApiKeysFailure = createAction(
|
||||||
|
'[ApiKey] Load Keys Failure',
|
||||||
|
props<{ error: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createApiKey = createAction(
|
||||||
|
'[ApiKey] Create Key',
|
||||||
|
props<{ request: CreateApiKeyRequest }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createApiKeySuccess = createAction(
|
||||||
|
'[ApiKey] Create Key Success',
|
||||||
|
props<{ response: CreateApiKeyResponse }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createApiKeyFailure = createAction(
|
||||||
|
'[ApiKey] Create Key Failure',
|
||||||
|
props<{ error: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const revokeApiKey = createAction(
|
||||||
|
'[ApiKey] Revoke Key',
|
||||||
|
props<{ keyId: string; ownerId?: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const revokeApiKeySuccess = createAction(
|
||||||
|
'[ApiKey] Revoke Key Success',
|
||||||
|
props<{ keyId: string; ownerId?: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const revokeApiKeyFailure = createAction(
|
||||||
|
'[ApiKey] Revoke Key Failure',
|
||||||
|
props<{ error: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dismissNewKey = createAction('[ApiKey] Dismiss New Key');
|
||||||
|
|
||||||
|
export const deleteApiKey = createAction(
|
||||||
|
'[ApiKey] Delete Key',
|
||||||
|
props<{ keyId: string; ownerId?: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteApiKeySuccess = createAction(
|
||||||
|
'[ApiKey] Delete Key Success',
|
||||||
|
props<{ keyId: string; ownerId?: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteApiKeyFailure = createAction(
|
||||||
|
'[ApiKey] Delete Key Failure',
|
||||||
|
props<{ error: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const regenerateApiKey = createAction(
|
||||||
|
'[ApiKey] Regenerate Key',
|
||||||
|
props<{ keyId: string; ownerId?: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const regenerateApiKeySuccess = createAction(
|
||||||
|
'[ApiKey] Regenerate Key Success',
|
||||||
|
props<{ response: CreateApiKeyResponse; ownerId?: string }>()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const regenerateApiKeyFailure = createAction(
|
||||||
|
'[ApiKey] Regenerate Key Failure',
|
||||||
|
props<{ error: string }>()
|
||||||
|
);
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
/* New Key Banner */
|
||||||
|
.new-key-banner {
|
||||||
|
background: #e8f5e9;
|
||||||
|
border: 1px solid #A5D6A7;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-key-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-key-header i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #2E7D32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-key-value-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-key-value {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #A5D6A7;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #2E7D32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.revoked-row {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
background: #A5D6A7;
|
||||||
|
color: #2E7D32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-revoked {
|
||||||
|
background: #ffcdd2;
|
||||||
|
color: #b71c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row expansion */
|
||||||
|
.row-expansion > td {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-top: none;
|
||||||
|
padding: 0.75rem 1rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 8.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.expansion-grid {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion-item {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
flex: unset;
|
||||||
|
min-width: unset;
|
||||||
|
padding: 0.5em 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion-label {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 1em;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansion-value {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create dialog */
|
||||||
|
.create-form {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #757575;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
@ -0,0 +1,244 @@
|
|||||||
|
<div class="ui-g" *ngIf="!toggleable; else toggleablePanel">
|
||||||
|
<div class="ui-g-12">
|
||||||
|
<div class="card">
|
||||||
|
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #toggleablePanel>
|
||||||
|
<p-panel i18n-header="@@apiKeys" header="API Keys"
|
||||||
|
[toggleable]="true" [collapsed]="collapsed">
|
||||||
|
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||||
|
</p-panel>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #content>
|
||||||
|
<p-messages *ngIf="error$ | async as err" severity="error">
|
||||||
|
<ng-template pTemplate>{{ err }}</ng-template>
|
||||||
|
</p-messages>
|
||||||
|
|
||||||
|
<!-- New key banner — shown after creation -->
|
||||||
|
<div *ngIf="newKey" class="new-key-banner">
|
||||||
|
<div class="new-key-header">
|
||||||
|
<i class="ui-icon-vpn-key"></i>
|
||||||
|
<span i18n="@@newKeyCreated">Key <strong>{{ newKey.label }}</strong><ng-container *ngIf="newKeyOwnerLabel"> for <strong>{{ newKeyOwnerLabel }}</strong></ng-container> created. Copy it now — it will not be shown again.</span>
|
||||||
|
</div>
|
||||||
|
<div class="new-key-value-row">
|
||||||
|
<code class="new-key-value">{{ newKey.key }}</code>
|
||||||
|
<button pButton type="button" icon="ui-icon-content-copy"
|
||||||
|
[label]="keyCopied ? ('Copied!' | titlecase) : 'Copy'"
|
||||||
|
[class]="keyCopied ? 'ui-button-secondary' : 'ui-button-primary'"
|
||||||
|
(click)="copyKey()">
|
||||||
|
</button>
|
||||||
|
<button pButton type="button" icon="ui-icon-close"
|
||||||
|
class="ui-button-secondary"
|
||||||
|
i18n-label="@@dismiss" label="Dismiss"
|
||||||
|
(click)="dismissNewKey()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic filters (admin only) -->
|
||||||
|
<p-accordion *ngIf="isAdmin && !isMasterAccount && !ownerId" styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||||
|
<p-accordionTab i18n-header="@@searchApiKeys" header="Search API Keys" [transitionOptions]="'250ms'"
|
||||||
|
[selected]="filterAccordionOpen"
|
||||||
|
(selectedChange)="filterAccordionOpen = $event; onAccordionToggle($event)">
|
||||||
|
<agm-dynamic-filter
|
||||||
|
[filterDefinitions]="filterDefinitions"
|
||||||
|
[locale]="locale"
|
||||||
|
[autoSaveOnChange]="true"
|
||||||
|
stateKey="api-key-manager-filters"
|
||||||
|
(filtersChanged)="onFiltersChanged($event)"
|
||||||
|
(filtersSubmit)="onFiltersSubmit($event)">
|
||||||
|
</agm-dynamic-filter>
|
||||||
|
</p-accordionTab>
|
||||||
|
</p-accordion>
|
||||||
|
|
||||||
|
<!-- Keys table -->
|
||||||
|
<p-table #dt [value]="filteredKeys$ | async" [columns]="cols" [loading]="loading$ | async"
|
||||||
|
[paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[10, 15, 30]"
|
||||||
|
[alwaysShowPaginator]="true" dataKey="_id" [responsive]="true"
|
||||||
|
selectionMode="single" [(selection)]="selectedKey"
|
||||||
|
[(expandedRowKeys)]="expandedRows"
|
||||||
|
[resetPageOnSort]="false">
|
||||||
|
|
||||||
|
<ng-template pTemplate="caption">
|
||||||
|
<span class="table-caption-1" i18n="@@apiKeys">API Keys</span>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template pTemplate="header" let-columns>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 3rem;"></th>
|
||||||
|
<th *ngFor="let col of columns" [pSortableColumn]="col.field">
|
||||||
|
{{ col.header }}
|
||||||
|
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th *ngFor="let col of columns" [ngSwitch]="col.field" class="ui-fluid">
|
||||||
|
<div class="input-with-icon" *ngSwitchCase="'owner.name'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text"
|
||||||
|
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
|
[value]="dt.filters[col.field]?.value || ''">
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon" *ngSwitchCase="'owner.username'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text"
|
||||||
|
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
|
[value]="dt.filters[col.field]?.value || ''">
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon" *ngSwitchCase="'owner.contact'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text"
|
||||||
|
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
|
[value]="dt.filters[col.field]?.value || ''">
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon" *ngSwitchCase="'label'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text"
|
||||||
|
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
|
[value]="dt.filters[col.field]?.value || ''">
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon" *ngSwitchCase="'prefix'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text"
|
||||||
|
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
|
[value]="dt.filters[col.field]?.value || ''">
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon" *ngSwitchCase="'service'">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text"
|
||||||
|
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
|
[value]="dt.filters[col.field]?.value || ''">
|
||||||
|
</div>
|
||||||
|
<p-dropdown *ngIf="col.field === 'active'" [options]="statusOptions" [style]="{'width':'100%'}"
|
||||||
|
[ngModel]="dt.filters['active']?.value"
|
||||||
|
(onChange)="dt.filter($event.value, 'active', 'equals')">
|
||||||
|
</p-dropdown>
|
||||||
|
<span *ngSwitchDefault></span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template pTemplate="body" let-key let-expanded="expanded">
|
||||||
|
<tr [class.revoked-row]="!key.active" [pSelectableRow]="key">
|
||||||
|
<td>
|
||||||
|
<button pButton type="button"
|
||||||
|
[icon]="expanded ? 'ui-icon-keyboard-arrow-up' : 'ui-icon-keyboard-arrow-down'"
|
||||||
|
class="ui-button-text ui-button-plain"
|
||||||
|
[pRowToggler]="key">
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@name">Name</span>{{ key.owner?.name || '—' }}</td>
|
||||||
|
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@userName">Username</span>{{ key.owner?.username || '—' }}</td>
|
||||||
|
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@contact">Contact</span>{{ key.owner?.contact || '—' }}</td>
|
||||||
|
<td><span class="ui-column-title" i18n="@@label">Label</span>{{ key.label }}<ng-container *ngIf="!isAdmin || ownerId"> <span [class]="key.active ? 'badge badge-active' : 'badge badge-revoked'">{{ key.active ? 'Active' : 'Revoked' }}</span></ng-container></td>
|
||||||
|
<td><span class="ui-column-title" i18n="@@prefix">Prefix</span><code>{{ key.prefix }}…</code></td>
|
||||||
|
<td><span class="ui-column-title" i18n="@@service">Service</span>{{ serviceLabels[key.service] || key.service }}</td>
|
||||||
|
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@status">Status</span><span [class]="key.active ? 'badge badge-active' : 'badge badge-revoked'">{{ key.active ? 'Active' : 'Revoked' }}</span></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template pTemplate="rowexpansion" let-key let-columns="columns">
|
||||||
|
<tr class="row-expansion">
|
||||||
|
<td [attr.colspan]="cols.length + 1">
|
||||||
|
<div class="expansion-grid">
|
||||||
|
<div class="expansion-item">
|
||||||
|
<span class="expansion-label" i18n="@@service">Service</span>
|
||||||
|
<span class="expansion-value">{{ serviceLabels[key.service] || key.service }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="expansion-item">
|
||||||
|
<span class="expansion-label" i18n="@@createdDate">Created Date</span>
|
||||||
|
<span class="expansion-value">{{ key.createdAt | date:'short' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="expansion-item">
|
||||||
|
<span class="expansion-label" i18n="@@lastUsed">Last Used</span>
|
||||||
|
<span class="expansion-value">{{ key.lastUsedAt ? (key.lastUsedAt | date:'short') : '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="expansion-item">
|
||||||
|
<span class="expansion-label" i18n="@@requests">Requests</span>
|
||||||
|
<span class="expansion-value">{{ key.requestCount != null ? (key.requestCount | number) : 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template pTemplate="emptymessage">
|
||||||
|
<tr>
|
||||||
|
<td [attr.colspan]="cols.length + 1" class="empty-message">
|
||||||
|
<span i18n="@@noApiKeys">No API keys yet. Click <strong>Generate Key</strong> to create one.</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
<div class="ui-widget-header ui-helper-clearfix toolbar">
|
||||||
|
<button type="button" pButton icon="ui-icon-add"
|
||||||
|
i18n-label="@@new" label="New"
|
||||||
|
(click)="openNewDialog()">
|
||||||
|
</button>
|
||||||
|
<button type="button" pButton icon="ui-icon-refresh"
|
||||||
|
[disabled]="!selectedKey"
|
||||||
|
i18n-label="@@regenerateKey" label="Regenerate"
|
||||||
|
(click)="confirmRegenerate(selectedKey)">
|
||||||
|
</button>
|
||||||
|
<button *ngIf="isAdmin" type="button" pButton icon="ui-icon-block"
|
||||||
|
[disabled]="!selectedKey || !selectedKey.active"
|
||||||
|
i18n-label="@@revokeKey" label="Revoke"
|
||||||
|
(click)="confirmRevoke(selectedKey)">
|
||||||
|
</button>
|
||||||
|
<button type="button" pButton icon="ui-icon-trash"
|
||||||
|
[disabled]="!selectedKey"
|
||||||
|
i18n-label="@@deleteKey" label="Delete"
|
||||||
|
(click)="confirmDelete(selectedKey)">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate Key dialog -->
|
||||||
|
<p-dialog i18n-header="@@generateKey" header="Generate Key"
|
||||||
|
[(visible)]="showNewDialog" [modal]="true" [responsive]="true"
|
||||||
|
[style]="{'width':'480px'}" [closable]="true">
|
||||||
|
<div class="create-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div *ngIf="isAdmin && !ownerId" class="form-field">
|
||||||
|
<label i18n="@@customer">Customer</label>
|
||||||
|
<p-dropdown [options]="customerOptions" [(ngModel)]="newKeyOwnerId"
|
||||||
|
i18n-placeholder="@@selectCustomer" placeholder="Select a customer..."
|
||||||
|
[filter]="true" filterBy="label" appendTo="body"
|
||||||
|
[style]="{'width':'100%'}">
|
||||||
|
</p-dropdown>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label i18n="@@service">Service</label>
|
||||||
|
<p-dropdown [options]="serviceOptions" [(ngModel)]="newService"
|
||||||
|
[style]="{'width':'100%'}">
|
||||||
|
</p-dropdown>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="keyLabel" i18n="@@keyLabel">Label</label>
|
||||||
|
<input id="keyLabel" pInputText type="text" [(ngModel)]="newKeyLabel"
|
||||||
|
i18n-placeholder="@@keyLabelPlaceholder" placeholder="e.g. Power BI connector"
|
||||||
|
class="full-width" maxlength="100" (keydown.enter)="submitCreate()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p-footer>
|
||||||
|
<button pButton type="button" icon="ui-icon-add"
|
||||||
|
i18n-label="@@generate" label="Generate"
|
||||||
|
[disabled]="!newKeyLabel.trim() || (isAdmin && !ownerId && !newKeyOwnerId)"
|
||||||
|
(click)="submitCreate()">
|
||||||
|
</button>
|
||||||
|
<button pButton type="button" icon="ui-icon-close"
|
||||||
|
class="ui-button-secondary"
|
||||||
|
i18n-label="@@cancel" label="Cancel"
|
||||||
|
(click)="showNewDialog = false">
|
||||||
|
</button>
|
||||||
|
</p-footer>
|
||||||
|
</p-dialog>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<p-toast key="apiKeyToast" position="bottom-center" life="3000"></p-toast>
|
||||||
|
<p-confirmDialog [style]="{ width: '420px' }"></p-confirmDialog>
|
||||||
@ -0,0 +1,310 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, OnChanges, SimpleChanges, Input, ViewChild } from '@angular/core';
|
||||||
|
import { Observable, Subject, BehaviorSubject, combineLatest } from 'rxjs';
|
||||||
|
import { takeUntil, map } from 'rxjs/operators';
|
||||||
|
import { Table } from 'primeng/table';
|
||||||
|
|
||||||
|
import { ApiKey, CreateApiKeyResponse } from '../models/api-key.model';
|
||||||
|
import { ApiKeyState, FEATURE_KEY } from '../../reducers';
|
||||||
|
import * as ApiKeyActions from '../../actions/api-key.actions';
|
||||||
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
|
import { RoleIds } from '@app/shared/global';
|
||||||
|
import { CustomerService } from '@app/domain/services/customer.service';
|
||||||
|
import { FilterDefinition, FilterChangeEvent, ActiveFilter } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'agm-api-key-manager',
|
||||||
|
templateUrl: './api-key-manager.component.html',
|
||||||
|
styleUrls: ['./api-key-manager.component.css'],
|
||||||
|
})
|
||||||
|
export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestroy, OnChanges {
|
||||||
|
@Input() ownerId: string;
|
||||||
|
@Input() toggleable = false;
|
||||||
|
@Input() collapsed = false;
|
||||||
|
@ViewChild('dt') dt!: Table;
|
||||||
|
keys$: Observable<ApiKey[]>;
|
||||||
|
filteredKeys$: Observable<ApiKey[]>;
|
||||||
|
loading$: Observable<boolean>;
|
||||||
|
error$: Observable<string | null>;
|
||||||
|
|
||||||
|
filterDefinitions: FilterDefinition[] = [];
|
||||||
|
filterAccordionOpen = sessionStorage.getItem('api-key-filter-accordion') === 'true';
|
||||||
|
private readonly activeFilters$ = new BehaviorSubject<ActiveFilter[]>([]);
|
||||||
|
|
||||||
|
newKey: CreateApiKeyResponse | null = null;
|
||||||
|
newKeyOwnerLabel: string | null = null;
|
||||||
|
newKeyLabel = '';
|
||||||
|
newService = 'data_export';
|
||||||
|
newKeyOwnerId: string | null = null;
|
||||||
|
keyCopied = false;
|
||||||
|
isAdmin = false;
|
||||||
|
isMasterAccount = false;
|
||||||
|
customerOptions: { label: string; value: string }[] = [];
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
cols: any[] = [];
|
||||||
|
expandedRows: { [id: string]: boolean } = {};
|
||||||
|
selectedKey: ApiKey | null = null;
|
||||||
|
showNewDialog = false;
|
||||||
|
|
||||||
|
createdAtFilter: Date | null = null;
|
||||||
|
lastUsedAtFilter: Date | null = null;
|
||||||
|
|
||||||
|
statusOptions = [
|
||||||
|
{ label: $localize`:@@all:All`, value: null },
|
||||||
|
{ label: $localize`:@@active:Active`, value: true },
|
||||||
|
{ label: $localize`:@@revoked:Revoked`, value: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
serviceOptions = [
|
||||||
|
{ label: $localize`:@@dataExportApi:Data Export API`, value: 'data_export' },
|
||||||
|
{ label: $localize`:@@partnerApi:Partner API`, value: 'partner_api' },
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly serviceLabels: Record<string, string> = {
|
||||||
|
data_export: $localize`:@@dataExportApi:Data Export API`,
|
||||||
|
partner_api: $localize`:@@partnerApi:Partner API`,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private readonly customerSvc: CustomerService) {
|
||||||
|
super();
|
||||||
|
this.keys$ = this.store.select((s: any) => s[FEATURE_KEY].keys);
|
||||||
|
this.loading$ = this.store.select((s: any) => s[FEATURE_KEY].loading);
|
||||||
|
this.error$ = this.store.select((s: any) => s[FEATURE_KEY].error);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.filteredKeys$ = combineLatest([
|
||||||
|
this.keys$.pipe(map(k => k || [])),
|
||||||
|
this.activeFilters$,
|
||||||
|
]).pipe(
|
||||||
|
map(([keys, filters]) => this.applyFilters(keys, filters))
|
||||||
|
);
|
||||||
|
|
||||||
|
this.isAdmin = this.authSvc.hasRole([RoleIds.ADMIN]);
|
||||||
|
this.isMasterAccount = this.authSvc.hasRole([RoleIds.APP]);
|
||||||
|
|
||||||
|
const serviceFilterOptions = [
|
||||||
|
{ label: $localize`:@@all:All`, value: null },
|
||||||
|
...this.serviceOptions.map(o => ({ label: o.label, value: o.value })),
|
||||||
|
];
|
||||||
|
const statusFilterOptions = [
|
||||||
|
{ label: $localize`:@@all:All`, value: null },
|
||||||
|
{ label: $localize`:@@active:Active`, value: true },
|
||||||
|
{ label: $localize`:@@revoked:Revoked`, value: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
this.filterDefinitions = [
|
||||||
|
{ key: 'label', label: $localize`:@@label:Label`, dataType: 'text' },
|
||||||
|
{ key: 'prefix', label: $localize`:@@prefix:Prefix`, dataType: 'text' },
|
||||||
|
{ key: 'service', label: $localize`:@@service:Service`, dataType: 'select', options: serviceFilterOptions },
|
||||||
|
{ key: 'active', label: $localize`:@@status:Status`, dataType: 'select', options: statusFilterOptions },
|
||||||
|
{ key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date' },
|
||||||
|
{ key: 'lastUsedAt', label: $localize`:@@lastUsed:Last Used`, dataType: 'date' },
|
||||||
|
{ key: 'requestCount', label: $localize`:@@requests:Requests`, dataType: 'number' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.isAdmin && !this.ownerId) {
|
||||||
|
this.filterDefinitions = [
|
||||||
|
{ key: 'owner.name', label: $localize`:@@name:Name`, dataType: 'text' },
|
||||||
|
{ key: 'owner.username', label: $localize`:@@userName:Username`, dataType: 'text' },
|
||||||
|
{ key: 'owner.contact', label: $localize`:@@contact:Contact`, dataType: 'text' },
|
||||||
|
...this.filterDefinitions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cols = [
|
||||||
|
{ field: 'label', header: $localize`:@@label:Label`, filtered: true, filterMatchMode: 'contains' },
|
||||||
|
{ field: 'prefix', header: $localize`:@@prefix:Prefix`, filtered: true, filterMatchMode: 'contains' },
|
||||||
|
{ field: 'service', header: $localize`:@@service:Service`, filtered: true, filterMatchMode: 'contains' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.isAdmin && !this.ownerId) {
|
||||||
|
this.cols = [
|
||||||
|
{ field: 'owner.name', header: $localize`:@@name:Name`, filtered: true, filterMatchMode: 'contains' },
|
||||||
|
{ field: 'owner.username', header: $localize`:@@userName:Username`, filtered: true, filterMatchMode: 'contains' },
|
||||||
|
{ field: 'owner.contact', header: $localize`:@@contact:Contact`, filtered: true, filterMatchMode: 'contains' },
|
||||||
|
...this.cols,
|
||||||
|
{ field: 'active', header: $localize`:@@status:Status` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.dispatch(ApiKeyActions.loadApiKeys({ ownerId: this.ownerId }));
|
||||||
|
|
||||||
|
if (this.isAdmin && !this.ownerId) {
|
||||||
|
this.customerSvc.loadCustomers().pipe(takeUntil(this.destroy$)).subscribe(customers => {
|
||||||
|
this.customerOptions = customers.map(c => ({ label: c.username || c.name || c._id, value: c._id }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.select((s: any) => s[FEATURE_KEY].newKey)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe(key => {
|
||||||
|
this.newKey = key;
|
||||||
|
if (key) { this.showNewDialog = false; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes.ownerId && !changes.ownerId.firstChange) {
|
||||||
|
this.store.dispatch(ApiKeyActions.dismissNewKey());
|
||||||
|
this.store.dispatch(ApiKeyActions.loadApiKeys({ ownerId: this.ownerId }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.store.dispatch(ApiKeyActions.dismissNewKey());
|
||||||
|
this.activeFilters$.complete();
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
openNewDialog(): void {
|
||||||
|
this.newKeyLabel = '';
|
||||||
|
this.newService = 'data_export';
|
||||||
|
this.newKeyOwnerId = null;
|
||||||
|
this.showNewDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCreate(): void {
|
||||||
|
const label = this.newKeyLabel.trim();
|
||||||
|
if (!label) { return; }
|
||||||
|
const effectiveOwnerId = this.ownerId || (this.isAdmin ? this.newKeyOwnerId : null);
|
||||||
|
if (this.isAdmin && !this.ownerId && !effectiveOwnerId) { return; }
|
||||||
|
const request: any = { label, service: this.newService };
|
||||||
|
if (effectiveOwnerId) { request.ownerId = effectiveOwnerId; }
|
||||||
|
const ownerOpt = this.customerOptions.find(o => o.value === effectiveOwnerId);
|
||||||
|
this.newKeyOwnerLabel = ownerOpt ? ownerOpt.label : null;
|
||||||
|
this.store.dispatch(ApiKeyActions.createApiKey({ request }));
|
||||||
|
this.newKeyLabel = '';
|
||||||
|
this.newService = 'data_export';
|
||||||
|
this.newKeyOwnerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissNewKey(): void {
|
||||||
|
this.newKey = null;
|
||||||
|
this.newKeyOwnerLabel = null;
|
||||||
|
this.store.dispatch(ApiKeyActions.dismissNewKey());
|
||||||
|
this.keyCopied = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyKey(): void {
|
||||||
|
if (!this.newKey?.key) { return; }
|
||||||
|
navigator.clipboard.writeText(this.newKey.key).then(() => {
|
||||||
|
this.keyCopied = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRegenerate(key: ApiKey): void {
|
||||||
|
this.confirmSvc.confirm({
|
||||||
|
message: $localize`:@@regenerateKeyConfirm:Regenerate the key "${key.label}"? The old key will stop working immediately.`,
|
||||||
|
header: $localize`:@@regenerateKey:Regenerate Key`,
|
||||||
|
icon: 'ui-icon-refresh',
|
||||||
|
accept: () => {
|
||||||
|
this.store.dispatch(ApiKeyActions.regenerateApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRevoke(key: ApiKey): void {
|
||||||
|
this.confirmSvc.confirm({
|
||||||
|
message: $localize`:@@revokeKeyConfirm:Revoke the key "${key.label}"? This cannot be undone.`,
|
||||||
|
header: $localize`:@@revokeKey:Revoke Key`,
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
accept: () => {
|
||||||
|
this.store.dispatch(ApiKeyActions.revokeApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete(key: ApiKey): void {
|
||||||
|
this.confirmSvc.confirm({
|
||||||
|
message: $localize`:@@deleteKeyConfirm:Permanently delete the key "${key.label}"? This action cannot be undone.`,
|
||||||
|
header: $localize`:@@deleteKey:Delete Key`,
|
||||||
|
icon: 'pi pi-trash',
|
||||||
|
accept: () => {
|
||||||
|
this.store.dispatch(ApiKeyActions.deleteApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFiltersChanged(event: FilterChangeEvent): void {
|
||||||
|
// Apply immediately only when a filter is removed or all are cleared
|
||||||
|
if (event.filters.length < this.activeFilters$.value.length) {
|
||||||
|
this.activeFilters$.next(event.filters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onFiltersSubmit(event: FilterChangeEvent): void {
|
||||||
|
this.activeFilters$.next(event.filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccordionToggle(expanded: boolean): void {
|
||||||
|
sessionStorage.setItem('api-key-filter-accordion', String(expanded));
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateFilter(value: Date, field: string): void {
|
||||||
|
this.dt.filter(value, field, 'dateIs');
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyFilters(keys: ApiKey[], filters: ActiveFilter[]): ApiKey[] {
|
||||||
|
if (!filters.length) { return keys; }
|
||||||
|
return keys.filter(key => {
|
||||||
|
// Left-to-right operator evaluation, matching server buildDynamicFilter logic:
|
||||||
|
// filter[i].operator describes how filter[i] combines with the accumulated result.
|
||||||
|
// e.g. A(and) B(and) C(or) D(and) → ((A ∧ B) ∨ C) ∧ D
|
||||||
|
let result = this.matchesFilter(key, filters[0]);
|
||||||
|
for (let i = 1; i < filters.length; i++) {
|
||||||
|
const match = this.matchesFilter(key, filters[i]);
|
||||||
|
result = filters[i].operator === 'or' ? result || match : result && match;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveField(obj: any, path: string): any {
|
||||||
|
return path.split('.').reduce((cur, part) => (cur != null ? cur[part] : undefined), obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesFilter(key: ApiKey, filter: ActiveFilter): boolean {
|
||||||
|
const val = this.resolveField(key, filter.definition.key);
|
||||||
|
const fval = filter.value;
|
||||||
|
|
||||||
|
if (fval == null || fval === '') { return true; }
|
||||||
|
|
||||||
|
switch (filter.definition.dataType) {
|
||||||
|
case 'text': {
|
||||||
|
const s = String(val ?? '').toLowerCase();
|
||||||
|
const q = String(fval).toLowerCase();
|
||||||
|
switch (filter.valueOperator) {
|
||||||
|
case 'startsWith': return s.startsWith(q);
|
||||||
|
case 'exact': return s === q;
|
||||||
|
default: return s.includes(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'select':
|
||||||
|
return val === fval;
|
||||||
|
case 'date': {
|
||||||
|
if (!val) { return false; }
|
||||||
|
const d = new Date(val).setHours(0, 0, 0, 0);
|
||||||
|
const fd = new Date(fval).setHours(0, 0, 0, 0);
|
||||||
|
switch (filter.valueOperator) {
|
||||||
|
case 'before': return d < fd;
|
||||||
|
case 'after': return d > fd;
|
||||||
|
default: return d === fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'number': {
|
||||||
|
const n = Number(val ?? 0);
|
||||||
|
const fn = Number(fval);
|
||||||
|
switch (filter.valueOperator) {
|
||||||
|
case 'greaterThan': return n > fn;
|
||||||
|
case 'lessThan': return n < fn;
|
||||||
|
default: return n === fn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule, TitleCasePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import { StoreModule } from '@ngrx/store';
|
||||||
|
import { EffectsModule } from '@ngrx/effects';
|
||||||
|
|
||||||
|
// PrimeNG
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
import { DropdownModule } from 'primeng/dropdown';
|
||||||
|
import { CalendarModule } from 'primeng/calendar';
|
||||||
|
import { TableModule } from 'primeng/table';
|
||||||
|
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||||
|
import { DialogModule } from 'primeng/dialog';
|
||||||
|
import { TooltipModule } from 'primeng/tooltip';
|
||||||
|
import { MessagesModule } from 'primeng/messages';
|
||||||
|
import { MessageModule } from 'primeng/message';
|
||||||
|
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||||
|
import { PanelModule } from 'primeng/panel';
|
||||||
|
import { ToastModule } from 'primeng/toast';
|
||||||
|
import { AccordionModule } from 'primeng/accordion';
|
||||||
|
import { ConfirmationService } from 'primeng/api';
|
||||||
|
|
||||||
|
// Store
|
||||||
|
import { FEATURE_KEY, apiKeyReducer } from '../reducers';
|
||||||
|
import { ApiKeyEffects } from '../effects/api-key.effects';
|
||||||
|
|
||||||
|
// Shared
|
||||||
|
import { AppSharedModule } from '@app/shared/app-shared.module';
|
||||||
|
|
||||||
|
// Service
|
||||||
|
import { ApiKeyService } from '@app/domain/services/api-key.service';
|
||||||
|
import { CustomerService } from '@app/domain/services/customer.service';
|
||||||
|
|
||||||
|
// Component
|
||||||
|
import { ApiKeyManagerComponent } from './api-key-manager/api-key-manager.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
ApiKeyManagerComponent
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
AppSharedModule,
|
||||||
|
StoreModule.forFeature(FEATURE_KEY, apiKeyReducer),
|
||||||
|
EffectsModule.forFeature([ApiKeyEffects]),
|
||||||
|
ButtonModule,
|
||||||
|
InputTextModule,
|
||||||
|
DropdownModule,
|
||||||
|
CalendarModule,
|
||||||
|
TableModule,
|
||||||
|
ConfirmDialogModule,
|
||||||
|
TooltipModule,
|
||||||
|
MessagesModule,
|
||||||
|
MessageModule,
|
||||||
|
ProgressSpinnerModule,
|
||||||
|
PanelModule,
|
||||||
|
ToastModule,
|
||||||
|
AccordionModule,
|
||||||
|
DialogModule,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ApiKeyManagerComponent
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
TitleCasePipe,
|
||||||
|
ConfirmationService,
|
||||||
|
ApiKeyService,
|
||||||
|
CustomerService,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ApiKeySharedModule {}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { AuthGuard } from '../../domain/guards/auth.guard';
|
||||||
|
import { RoleIds } from '../../shared/global';
|
||||||
|
import { ApiKeyManagerComponent } from './api-key-manager/api-key-manager.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: ApiKeyManagerComponent,
|
||||||
|
data: {
|
||||||
|
roles: [RoleIds.ADMIN, RoleIds.APP, RoleIds.APP_ADM]
|
||||||
|
},
|
||||||
|
canActivate: [AuthGuard]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class ApiKeysRoutingModule {}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
// Routing
|
||||||
|
import { ApiKeysRoutingModule } from './api-keys-routing.module';
|
||||||
|
|
||||||
|
// Shared module (declares + exports ApiKeyManagerComponent, registers store/effects)
|
||||||
|
import { ApiKeySharedModule } from './api-key-shared.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
ApiKeysRoutingModule,
|
||||||
|
ApiKeySharedModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ApiKeysModule {}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
export interface ApiKey {
|
||||||
|
_id: string;
|
||||||
|
label: string;
|
||||||
|
prefix: string;
|
||||||
|
active: boolean;
|
||||||
|
service: string;
|
||||||
|
managedBy: 'owner' | 'admin';
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
requestCount: number;
|
||||||
|
owner?: string | { _id: string; username: string; name?: string; contact?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyResponse extends ApiKey {
|
||||||
|
key: string; // plain key — returned once only
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyRequest {
|
||||||
|
label: string;
|
||||||
|
service: string;
|
||||||
|
ownerId?: string; // admin only
|
||||||
|
}
|
||||||
112
Development/client/src/app/settings/effects/api-key.effects.ts
Normal file
112
Development/client/src/app/settings/effects/api-key.effects.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { map, mergeMap, catchError, repeat } from 'rxjs/operators';
|
||||||
|
import { MessageService } from 'primeng/api';
|
||||||
|
|
||||||
|
import { ApiKeyService } from '@app/domain/services/api-key.service';
|
||||||
|
import * as ApiKeyActions from '../actions/api-key.actions';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyEffects {
|
||||||
|
constructor(
|
||||||
|
private readonly actions$: Actions,
|
||||||
|
private readonly apiKeySvc: ApiKeyService,
|
||||||
|
private readonly messageSvc: MessageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
loadKeys$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(ApiKeyActions.loadApiKeys),
|
||||||
|
mergeMap(action =>
|
||||||
|
this.apiKeySvc.listKeys(action.ownerId).pipe(
|
||||||
|
map(keys => ApiKeyActions.loadApiKeysSuccess({ keys })),
|
||||||
|
catchError(err => of(ApiKeyActions.loadApiKeysFailure({ error: err.message })))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
repeat()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
createKey$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(ApiKeyActions.createApiKey),
|
||||||
|
mergeMap(action =>
|
||||||
|
this.apiKeySvc.createKey(action.request).pipe(
|
||||||
|
map(response => ApiKeyActions.createApiKeySuccess({ response })),
|
||||||
|
catchError(err => of(ApiKeyActions.createApiKeyFailure({ error: err.error?.message || err.message })))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
repeat()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
revokeKey$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(ApiKeyActions.revokeApiKey),
|
||||||
|
mergeMap(action =>
|
||||||
|
this.apiKeySvc.revokeKey(action.keyId).pipe(
|
||||||
|
map(() => ApiKeyActions.revokeApiKeySuccess({ keyId: action.keyId, ownerId: action.ownerId })),
|
||||||
|
catchError(err => of(ApiKeyActions.revokeApiKeyFailure({ error: err.error?.message || err.message })))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
repeat()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
revokeSuccess$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(ApiKeyActions.revokeApiKeySuccess),
|
||||||
|
map(action => {
|
||||||
|
this.messageSvc.add({ key: 'apiKeyToast', severity: 'success', summary: 'Key Revoked', detail: 'API key has been revoked.' });
|
||||||
|
return ApiKeyActions.loadApiKeys({ ownerId: action.ownerId });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
deleteKey$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(ApiKeyActions.deleteApiKey),
|
||||||
|
mergeMap(action =>
|
||||||
|
this.apiKeySvc.deleteKey(action.keyId).pipe(
|
||||||
|
map(() => ApiKeyActions.deleteApiKeySuccess({ keyId: action.keyId, ownerId: action.ownerId })),
|
||||||
|
catchError(err => of(ApiKeyActions.deleteApiKeyFailure({ error: err.error?.message || err.message })))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
repeat()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
deleteSuccess$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(ApiKeyActions.deleteApiKeySuccess),
|
||||||
|
map(action => {
|
||||||
|
this.messageSvc.add({ key: 'apiKeyToast', severity: 'success', summary: 'Key Deleted', detail: 'API key has been permanently deleted.' });
|
||||||
|
return ApiKeyActions.loadApiKeys({ ownerId: action.ownerId });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
regenerateKey$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(ApiKeyActions.regenerateApiKey),
|
||||||
|
mergeMap(action =>
|
||||||
|
this.apiKeySvc.regenerateKey(action.keyId).pipe(
|
||||||
|
map(response => ApiKeyActions.regenerateApiKeySuccess({ response, ownerId: action.ownerId })),
|
||||||
|
catchError(err => of(ApiKeyActions.regenerateApiKeyFailure({ error: err.error?.message || err.message })))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
repeat()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
failure$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(ApiKeyActions.loadApiKeysFailure, ApiKeyActions.createApiKeyFailure, ApiKeyActions.revokeApiKeyFailure, ApiKeyActions.deleteApiKeyFailure, ApiKeyActions.regenerateApiKeyFailure),
|
||||||
|
map(action => {
|
||||||
|
this.messageSvc.add({ key: 'apiKeyToast', severity: 'error', summary: 'Error', detail: action.error });
|
||||||
|
return { type: '[ApiKey] Noop' };
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
import { createReducer, on } from '@ngrx/store';
|
||||||
|
import { ApiKey, CreateApiKeyResponse } from '../api-keys/models/api-key.model';
|
||||||
|
import * as ApiKeyActions from '../actions/api-key.actions';
|
||||||
|
|
||||||
|
export interface ApiKeyState {
|
||||||
|
keys: ApiKey[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
newKey: CreateApiKeyResponse | null; // holds the just-created key (plain key visible once)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialState: ApiKeyState = {
|
||||||
|
keys: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
newKey: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FEATURE_KEY = 'apiKey';
|
||||||
|
|
||||||
|
export const apiKeyReducer = createReducer(
|
||||||
|
initialState,
|
||||||
|
|
||||||
|
on(ApiKeyActions.loadApiKeys, (state) => ({
|
||||||
|
...state, loading: true, error: null
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.loadApiKeysSuccess, (state, { keys }) => ({
|
||||||
|
...state, keys, loading: false
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.loadApiKeysFailure, (state, { error }) => ({
|
||||||
|
...state, loading: false, error
|
||||||
|
})),
|
||||||
|
|
||||||
|
on(ApiKeyActions.createApiKey, (state) => ({
|
||||||
|
...state, loading: true, error: null
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.createApiKeySuccess, (state, { response }) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
newKey: response,
|
||||||
|
// Add new key to list (without the plain key field)
|
||||||
|
keys: [{ _id: response._id, label: response.label, prefix: response.prefix,
|
||||||
|
active: response.active, service: response.service, managedBy: response.managedBy,
|
||||||
|
createdAt: response.createdAt, owner: response.owner }, ...state.keys],
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.createApiKeyFailure, (state, { error }) => ({
|
||||||
|
...state, loading: false, error
|
||||||
|
})),
|
||||||
|
|
||||||
|
on(ApiKeyActions.revokeApiKey, (state) => ({
|
||||||
|
...state, loading: true, error: null
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.revokeApiKeySuccess, (state, { keyId }) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
keys: state.keys.map(k => k._id === keyId ? { ...k, active: false } : k),
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.revokeApiKeyFailure, (state, { error }) => ({
|
||||||
|
...state, loading: false, error
|
||||||
|
})),
|
||||||
|
|
||||||
|
on(ApiKeyActions.deleteApiKey, (state) => ({
|
||||||
|
...state, loading: true, error: null
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.deleteApiKeySuccess, (state, { keyId }) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
keys: state.keys.filter(k => k._id !== keyId),
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.deleteApiKeyFailure, (state, { error }) => ({
|
||||||
|
...state, loading: false, error
|
||||||
|
})),
|
||||||
|
|
||||||
|
on(ApiKeyActions.regenerateApiKey, (state) => ({
|
||||||
|
...state, loading: true, error: null
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.regenerateApiKeySuccess, (state, { response }) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
newKey: response,
|
||||||
|
keys: state.keys.map(k => k._id === response._id
|
||||||
|
? { ...k, prefix: response.prefix, active: true }
|
||||||
|
: k),
|
||||||
|
})),
|
||||||
|
on(ApiKeyActions.regenerateApiKeyFailure, (state, { error }) => ({
|
||||||
|
...state, loading: false, error
|
||||||
|
})),
|
||||||
|
|
||||||
|
on(ApiKeyActions.dismissNewKey, (state) => ({
|
||||||
|
...state, newKey: null
|
||||||
|
})),
|
||||||
|
);
|
||||||
9
Development/client/src/app/settings/reducers/index.ts
Normal file
9
Development/client/src/app/settings/reducers/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { createFeatureSelector } from '@ngrx/store';
|
||||||
|
|
||||||
|
import * as fromApiKey from './api-key.reducer';
|
||||||
|
|
||||||
|
export { FEATURE_KEY } from './api-key.reducer';
|
||||||
|
export type { ApiKeyState } from './api-key.reducer';
|
||||||
|
export { apiKeyReducer } from './api-key.reducer';
|
||||||
|
|
||||||
|
export const getApiKeyState = createFeatureSelector<fromApiKey.ApiKeyState>(fromApiKey.FEATURE_KEY);
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { SharedModule } from 'primeng/api';
|
import { SharedModule } from 'primeng/api';
|
||||||
import { InputTextModule } from 'primeng/inputtext';
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
@ -14,6 +14,7 @@ import { MessageModule } from 'primeng/message';
|
|||||||
import { RadioButtonModule } from 'primeng/radiobutton';
|
import { RadioButtonModule } from 'primeng/radiobutton';
|
||||||
import { CalendarModule } from 'primeng/calendar';
|
import { CalendarModule } from 'primeng/calendar';
|
||||||
import { DialogModule } from 'primeng/dialog';
|
import { DialogModule } from 'primeng/dialog';
|
||||||
|
import { MultiSelectModule } from 'primeng/multiselect';
|
||||||
|
|
||||||
import { LengthUnitPipe } from './pipes/length-unit.pipe';
|
import { LengthUnitPipe } from './pipes/length-unit.pipe';
|
||||||
import { RateUnitPipe } from './pipes/rate-unit.pipe';
|
import { RateUnitPipe } from './pipes/rate-unit.pipe';
|
||||||
@ -76,12 +77,14 @@ import { BadgeComponent } from './badge/badge.component';
|
|||||||
import { PromoLabelComponent } from './promo-label/promo-label.component';
|
import { PromoLabelComponent } from './promo-label/promo-label.component';
|
||||||
import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component';
|
import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component';
|
||||||
import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component';
|
import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component';
|
||||||
|
import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.component';
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule,
|
CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule,
|
||||||
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule
|
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule,
|
||||||
|
MultiSelectModule, FormsModule
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
LengthUnitPipe, RateUnitPipe, UserTypePipe, AreaUnitPipe, NoCommaPipe,
|
LengthUnitPipe, RateUnitPipe, UserTypePipe, AreaUnitPipe, NoCommaPipe,
|
||||||
@ -90,18 +93,19 @@ import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-
|
|||||||
JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
||||||
DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent,
|
DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent,
|
||||||
PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
|
PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
|
||||||
|
DynamicFilterComponent
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule,
|
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule, FormsModule,
|
||||||
InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, RadioButtonModule,
|
InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, RadioButtonModule, MultiSelectModule,
|
||||||
ItemEditorComponent, ProductEditorComponent, AccountEditorComponent, DisplayConfigComponent, CropEditorComponent,
|
ItemEditorComponent, ProductEditorComponent, AccountEditorComponent, DisplayConfigComponent, CropEditorComponent,
|
||||||
LengthUnitPipe, RateUnitPipe, AreaUnitPipe, UserTypePipe, NoCommaPipe, UniqueUserValidatorDirective, UnitPipe, ProductTypePipe,
|
LengthUnitPipe, RateUnitPipe, AreaUnitPipe, UserTypePipe, NoCommaPipe, UniqueUserValidatorDirective, UnitPipe, ProductTypePipe,
|
||||||
ActivityPipe, CoordinatePipe, SpeedPipe, LengthPipe, TemperaturePipe, AppRatePipe, DistancePipe, JobStatusPipe,
|
ActivityPipe, CoordinatePipe, SpeedPipe, LengthPipe, TemperaturePipe, AppRatePipe, DistancePipe, JobStatusPipe,
|
||||||
VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, AppVolumePipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, AppVolumePipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
||||||
DebounceDirective, UnitIdUniqueDirective,
|
DebounceDirective, UnitIdUniqueDirective,
|
||||||
ProfileFormComponent, CreditcardFormComponent, CardInfoComponent,
|
ProfileFormComponent, CreditcardFormComponent, CardInfoComponent,
|
||||||
PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent
|
PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
|
||||||
|
DynamicFilterComponent
|
||||||
],
|
],
|
||||||
providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe]
|
providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe]
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,213 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-filter {
|
||||||
|
min-width: 16.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-add-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-add-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-action-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .filter-selector-dropdown {
|
||||||
|
min-width: 180px;
|
||||||
|
width: 220px;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .logic-operator-dropdown {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .value-operator-dropdown {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field {
|
||||||
|
padding: 0.2rem 0.25rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width:16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.filter-field {
|
||||||
|
width: calc(100% / 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filter-field {
|
||||||
|
width: calc(100% / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.filter-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field-inner {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field-header label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field-header .remove-btn {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .filter-field-header .remove-btn .ui-button-icon {
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .filter-field-header .remove-btn:hover .ui-button-icon {
|
||||||
|
color: #b71c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep body .ui-button.remove-btn .pi,
|
||||||
|
:host ::ng-deep .filter-field-header .remove-btn .pi {
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .filter-field-header .remove-btn:hover .pi {
|
||||||
|
color: #b71c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field-body {
|
||||||
|
padding: 0.25rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-operators-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
border-bottom: 1px solid #a6a6a6;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input-row:hover {
|
||||||
|
border-bottom-color: #007ad9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input-icon {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #555;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input-row span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-placeholder {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-clear-btn {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-clear-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-cal-anchor {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-cal-anchor ::ng-deep .ui-calendar {
|
||||||
|
display: block;
|
||||||
|
height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-cal-anchor ::ng-deep .ui-calendar .ui-inputtext {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-cal-anchor ::ng-deep .ui-calendar .ui-calendar-button {
|
||||||
|
visibility: hidden;
|
||||||
|
width: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
<div class="dynamic-filter">
|
||||||
|
<!-- Add filter row -->
|
||||||
|
<div class="filter-add-row">
|
||||||
|
<div class="filter-add-group">
|
||||||
|
<p-dropdown [options]="availableFilters" [(ngModel)]="selectedFilterKey"
|
||||||
|
styleClass="filter-selector-dropdown" placeholder="-- Select Filter --" appendTo="body">
|
||||||
|
</p-dropdown>
|
||||||
|
<button pButton type="button" icon="pi pi-plus" class="ui-button-success add-btn"
|
||||||
|
[disabled]="!selectedFilterKey" (click)="addFilter()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-action-group" *ngIf="activeFilters.length">
|
||||||
|
<button pButton type="button" icon="ui-icon-clear-all"
|
||||||
|
class="ui-button-secondary clear-btn" (click)="clearAll()" i18n-label="@@clearFilters" label="Clear Filters">
|
||||||
|
</button>
|
||||||
|
<button *ngIf="showSearch" pButton type="button" icon="pi pi-search"
|
||||||
|
class="ui-button-primary submit-btn" (click)="submit()" i18n-label="@@applyFilters" label="Search">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active filters -->
|
||||||
|
<div class="filter-grid" *ngIf="activeFilters.length">
|
||||||
|
<div class="filter-field" *ngFor="let filter of activeFilters; let i = index">
|
||||||
|
<div class="filter-field-inner">
|
||||||
|
<!-- Header: label + remove -->
|
||||||
|
<div class="filter-field-header">
|
||||||
|
<label>{{ filter.definition.label }}</label>
|
||||||
|
<button pButton type="button" icon="pi pi-times" class="ui-button-text remove-btn"
|
||||||
|
(click)="removeFilter(filter.id)">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-field-body">
|
||||||
|
<!-- And/Or + Label + Value operator row -->
|
||||||
|
<div class="filter-operators-row">
|
||||||
|
<p-dropdown *ngIf="i > 0" [options]="[{label: 'And', value: 'and'}, {label: 'Or', value: 'or'}]"
|
||||||
|
[(ngModel)]="filter.operator" styleClass="logic-operator-dropdown"
|
||||||
|
(onChange)="onOperatorChange()" appendTo="body">
|
||||||
|
</p-dropdown>
|
||||||
|
<p>{{ filter.definition.label }}</p>
|
||||||
|
<p *ngIf="filter.definition.dataType === 'select' || filter.definition.dataType === 'select-multi' || filter.definition.dataType === 'numeric-enum'">is</p>
|
||||||
|
<p-dropdown *ngIf="getValueOperatorOptions(filter.definition.dataType).length"
|
||||||
|
[options]="getValueOperatorOptions(filter.definition.dataType)"
|
||||||
|
[(ngModel)]="filter.valueOperator" styleClass="value-operator-dropdown"
|
||||||
|
(onChange)="onValueOperatorChange(filter)" appendTo="body">
|
||||||
|
</p-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text input -->
|
||||||
|
<input *ngIf="filter.definition.dataType === 'text'" pInputText type="text"
|
||||||
|
[(ngModel)]="filter.value" (input)="onValueChange()" placeholder="Search..." class="full-width">
|
||||||
|
|
||||||
|
<!-- Number input -->
|
||||||
|
<input *ngIf="filter.definition.dataType === 'number'" pInputText type="number"
|
||||||
|
[(ngModel)]="filter.value" (input)="onValueChange()" placeholder="Enter number..." class="full-width">
|
||||||
|
|
||||||
|
<!-- Select — single select -->
|
||||||
|
<p-dropdown *ngIf="filter.definition.dataType === 'select'"
|
||||||
|
[options]="filter.definition.options" [(ngModel)]="filter.value"
|
||||||
|
styleClass="full-width" [filter]="true" (onChange)="onValueChange()"
|
||||||
|
placeholder="Select..." appendTo="body">
|
||||||
|
</p-dropdown>
|
||||||
|
|
||||||
|
<!-- Select — multi select -->
|
||||||
|
<p-multiSelect *ngIf="filter.definition.dataType === 'select-multi'"
|
||||||
|
[options]="filter.definition.options" [(ngModel)]="filter.value"
|
||||||
|
styleClass="full-width" (onChange)="onValueChange()"
|
||||||
|
defaultLabel="Select..." appendTo="body">
|
||||||
|
</p-multiSelect>
|
||||||
|
|
||||||
|
<!-- Date — single date (before / after / exact) -->
|
||||||
|
<ng-container *ngIf="filter.definition.dataType === 'date' && filter.valueOperator !== 'range'">
|
||||||
|
<div class="date-cal-anchor">
|
||||||
|
<div class="date-input-row" (click)="openCal(filter.id, false)">
|
||||||
|
<i class="pi pi-calendar date-input-icon"></i>
|
||||||
|
<span *ngIf="!filter.value" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
|
||||||
|
<span *ngIf="filter.value">{{ filter.value | date:'shortDate' }}</span>
|
||||||
|
<i *ngIf="filter.value" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
|
||||||
|
</div>
|
||||||
|
<p-calendar [attr.data-filter-cal]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
|
||||||
|
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
|
||||||
|
(onClearClick)="onValueChange()" [showButtonBar]="true">
|
||||||
|
</p-calendar>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Date — range mode -->
|
||||||
|
<ng-container *ngIf="filter.definition.dataType === 'date' && filter.valueOperator === 'range'">
|
||||||
|
<div class="date-cal-anchor">
|
||||||
|
<div class="date-input-row" (click)="openCal(filter.id, true)">
|
||||||
|
<i class="pi pi-calendar date-input-icon"></i>
|
||||||
|
<span *ngIf="!filter.value || !filter.value[0]" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
|
||||||
|
<span *ngIf="filter.value && filter.value[0] && !filter.value[1]">{{ filter.value[0] | date:'shortDate' }}</span>
|
||||||
|
<span *ngIf="filter.value && filter.value[0] && filter.value[1]">{{ filter.value[0] | date:'shortDate' }} - {{ filter.value[1] | date:'shortDate' }}</span>
|
||||||
|
<i *ngIf="filter.value" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
|
||||||
|
</div>
|
||||||
|
<p-calendar [attr.data-filter-cal-range]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
|
||||||
|
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
|
||||||
|
(onClearClick)="onValueChange()" [showButtonBar]="true"
|
||||||
|
selectionMode="range" [readonlyInput]="true">
|
||||||
|
</p-calendar>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Date preset — dropdown with presets + optional custom calendar -->
|
||||||
|
<ng-container *ngIf="filter.definition.dataType === 'date-preset'">
|
||||||
|
<p-dropdown [options]="datePresetOptions"
|
||||||
|
[ngModel]="datePresetSelected.get(filter.id) || null"
|
||||||
|
styleClass="full-width" placeholder="-- Select --"
|
||||||
|
(onChange)="onDatePresetChange(filter, $event)" appendTo="body">
|
||||||
|
</p-dropdown>
|
||||||
|
<ng-container *ngIf="isDatePresetCustom(filter.id)">
|
||||||
|
<div class="date-cal-anchor" style="margin-top: 0.25rem;">
|
||||||
|
<div class="date-input-row" (click)="openCal(filter.id, false)">
|
||||||
|
<i class="pi pi-calendar date-input-icon"></i>
|
||||||
|
<span *ngIf="!filter.value" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
|
||||||
|
<span *ngIf="filter.value">{{ filter.value | date:'shortDate' }}</span>
|
||||||
|
<i *ngIf="filter.value" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
|
||||||
|
</div>
|
||||||
|
<p-calendar [attr.data-filter-cal]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
|
||||||
|
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
|
||||||
|
(onClearClick)="onValueChange()" [showButtonBar]="true">
|
||||||
|
</p-calendar>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div><!-- /.filter-field-body -->
|
||||||
|
</div><!-- /.filter-field-inner -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,378 @@
|
|||||||
|
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
|
||||||
|
import { SelectItem } from 'primeng/api';
|
||||||
|
|
||||||
|
export type FilterDataType = 'text' | 'select' | 'select-multi' | 'date' | 'date-preset' | 'number';
|
||||||
|
|
||||||
|
export type TextValueOperator = 'contains' | 'startsWith' | 'exact';
|
||||||
|
export type SelectValueOperator = 'multi';
|
||||||
|
export type DateValueOperator = 'before' | 'after' | 'exact' | 'range';
|
||||||
|
export type NumberValueOperator = 'exact' | 'greaterThan' | 'lessThan';
|
||||||
|
export type ValueOperator = TextValueOperator | SelectValueOperator | DateValueOperator | NumberValueOperator;
|
||||||
|
|
||||||
|
export interface FilterDefinition {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
dataType: FilterDataType;
|
||||||
|
options?: SelectItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterOperator = 'and' | 'or';
|
||||||
|
|
||||||
|
export interface ActiveFilter {
|
||||||
|
id: number;
|
||||||
|
definition: FilterDefinition;
|
||||||
|
value: any;
|
||||||
|
operator: FilterOperator;
|
||||||
|
valueOperator: ValueOperator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterChangeEvent {
|
||||||
|
filters: ActiveFilter[];
|
||||||
|
query: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VALUE_OPERATOR_OPTIONS: Record<FilterDataType, SelectItem[]> = {
|
||||||
|
text: [
|
||||||
|
{ label: 'Contains', value: 'contains' },
|
||||||
|
{ label: 'Starts With', value: 'startsWith' },
|
||||||
|
{ label: 'Is', value: 'exact' },
|
||||||
|
],
|
||||||
|
select: [],
|
||||||
|
'select-multi': [],
|
||||||
|
date: [
|
||||||
|
{ label: 'Before', value: 'before' },
|
||||||
|
{ label: 'After', value: 'after' },
|
||||||
|
{ label: 'Is', value: 'exact' },
|
||||||
|
{ label: 'Between', value: 'range' },
|
||||||
|
],
|
||||||
|
'date-preset': [],
|
||||||
|
number: [
|
||||||
|
{ label: 'Is', value: 'exact' },
|
||||||
|
{ label: 'Greater Than', value: 'greaterThan' },
|
||||||
|
{ label: 'Less Than', value: 'lessThan' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_VALUE_OPERATOR: Record<FilterDataType, ValueOperator> = {
|
||||||
|
text: 'contains',
|
||||||
|
select: 'multi',
|
||||||
|
'select-multi': 'multi',
|
||||||
|
date: 'exact',
|
||||||
|
'date-preset': 'exact',
|
||||||
|
number: 'exact',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert active filters into a plain query object for API requests.
|
||||||
|
*
|
||||||
|
* Each filter produces a key in the result whose value depends on the
|
||||||
|
* data type and operator. Consumers can map these keys to their own
|
||||||
|
* API parameter names.
|
||||||
|
*/
|
||||||
|
export function buildFilterQuery(activeFilters: ActiveFilter[]): Record<string, any> {
|
||||||
|
const query: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const f of activeFilters) {
|
||||||
|
if (f.value == null) { continue; }
|
||||||
|
if (f.definition.dataType === 'text' && f.value === '') { continue; }
|
||||||
|
if (f.definition.dataType === 'select' && f.value == null) { continue; }
|
||||||
|
if (f.definition.dataType === 'select-multi' && (!Array.isArray(f.value) || f.value.length === 0)) { continue; }
|
||||||
|
if (f.definition.dataType === 'date' && f.valueOperator === 'range'
|
||||||
|
&& (!Array.isArray(f.value) || f.value[0] == null)) { continue; }
|
||||||
|
if (f.definition.dataType === 'date-preset' && f.value == null) { continue; }
|
||||||
|
|
||||||
|
const hasValueOperator = VALUE_OPERATOR_OPTIONS[f.definition.dataType]?.length > 0;
|
||||||
|
query[f.definition.key] = {
|
||||||
|
value: f.value,
|
||||||
|
operator: f.operator,
|
||||||
|
...(hasValueOperator ? { valueOperator: f.valueOperator } : {}),
|
||||||
|
dataType: f.definition.dataType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'agm-dynamic-filter',
|
||||||
|
templateUrl: './dynamic-filter.component.html',
|
||||||
|
styleUrls: ['./dynamic-filter.component.css']
|
||||||
|
})
|
||||||
|
export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||||
|
@Input() filterDefinitions: FilterDefinition[] = [];
|
||||||
|
@Input() locale: any = {};
|
||||||
|
@Input() stateKey: string;
|
||||||
|
@Input() defaultFilters: Array<{ key: string; value: any }> = [];
|
||||||
|
@Input() showSearch = true;
|
||||||
|
@Input() autoSaveOnChange = false;
|
||||||
|
|
||||||
|
@Output() filtersChanged = new EventEmitter<FilterChangeEvent>();
|
||||||
|
@Output() filtersSubmit = new EventEmitter<FilterChangeEvent>();
|
||||||
|
|
||||||
|
availableFilters: SelectItem[] = [];
|
||||||
|
selectedFilterKey: string | null = null;
|
||||||
|
activeFilters: ActiveFilter[] = [];
|
||||||
|
datePresetOptions: SelectItem[] = [];
|
||||||
|
datePresetSelected = new Map<number, string>();
|
||||||
|
|
||||||
|
private nextId = 1;
|
||||||
|
|
||||||
|
constructor(private readonly el: ElementRef) {}
|
||||||
|
|
||||||
|
private stateRestored = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.buildAvailableFilters();
|
||||||
|
this.buildDatePresetOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes.filterDefinitions && this.filterDefinitions?.length && !this.stateRestored) {
|
||||||
|
this.restoreState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDatePresetOptions(): void {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
this.datePresetOptions = [
|
||||||
|
{ label: '-- Select --', value: null },
|
||||||
|
{ label: 'Past 1 Month', value: '1m' },
|
||||||
|
{ label: 'Past 3 Months', value: '3m' },
|
||||||
|
{ label: 'Past 6 Months', value: '6m' },
|
||||||
|
{ label: String(year), value: String(year) },
|
||||||
|
{ label: String(year - 1), value: String(year - 1) },
|
||||||
|
{ label: String(year - 2), value: String(year - 2) },
|
||||||
|
{ label: 'Custom', value: 'custom' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
addFilter(): void {
|
||||||
|
if (!this.selectedFilterKey) { return; }
|
||||||
|
const def = this.filterDefinitions.find(f => f.key === this.selectedFilterKey);
|
||||||
|
if (!def) { return; }
|
||||||
|
|
||||||
|
const defaultOp = DEFAULT_VALUE_OPERATOR[def.dataType];
|
||||||
|
const filter: ActiveFilter = {
|
||||||
|
id: this.nextId++,
|
||||||
|
definition: def,
|
||||||
|
value: this.getDefaultValue(def, defaultOp),
|
||||||
|
operator: 'and',
|
||||||
|
valueOperator: defaultOp
|
||||||
|
};
|
||||||
|
|
||||||
|
this.activeFilters.push(filter);
|
||||||
|
if (def.dataType === 'date-preset') {
|
||||||
|
this.datePresetSelected.set(filter.id, filter.value);
|
||||||
|
}
|
||||||
|
this.selectedFilterKey = null;
|
||||||
|
this.buildAvailableFilters();
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
getValueOperatorOptions(dataType: FilterDataType): SelectItem[] {
|
||||||
|
return VALUE_OPERATOR_OPTIONS[dataType] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onValueOperatorChange(filter: ActiveFilter): void {
|
||||||
|
// Reset value when operator changes to avoid type mismatches
|
||||||
|
filter.value = this.getDefaultValue(filter.definition, filter.valueOperator);
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFilter(id: number): void {
|
||||||
|
this.activeFilters = this.activeFilters.filter(f => f.id !== id);
|
||||||
|
this.datePresetSelected.delete(id);
|
||||||
|
this.buildAvailableFilters();
|
||||||
|
this.emitChange();
|
||||||
|
this.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onValueChange(): void {
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
onOperatorChange(): void {
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
const event: FilterChangeEvent = {
|
||||||
|
filters: [...this.activeFilters],
|
||||||
|
query: buildFilterQuery(this.activeFilters)
|
||||||
|
};
|
||||||
|
this.saveState();
|
||||||
|
this.filtersSubmit.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.activeFilters = [];
|
||||||
|
this.selectedFilterKey = null;
|
||||||
|
this.datePresetSelected.clear();
|
||||||
|
this.buildAvailableFilters();
|
||||||
|
this.clearState();
|
||||||
|
this.emitChange();
|
||||||
|
this.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDatePresetChange(filter: ActiveFilter, event: any): void {
|
||||||
|
const key = event.value;
|
||||||
|
if (!key) {
|
||||||
|
filter.value = null;
|
||||||
|
this.datePresetSelected.delete(filter.id);
|
||||||
|
this.emitChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.datePresetSelected.set(filter.id, key);
|
||||||
|
if (key === 'custom') {
|
||||||
|
filter.value = null;
|
||||||
|
} else {
|
||||||
|
filter.value = key;
|
||||||
|
}
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
isDatePresetCustom(filterId: number): boolean {
|
||||||
|
return this.datePresetSelected.get(filterId) === 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
openCal(filterId: number, isRange: boolean): void {
|
||||||
|
const attr = isRange ? `data-filter-cal-range` : `data-filter-cal`;
|
||||||
|
const calHost = this.el.nativeElement.querySelector(`[${attr}="${filterId}"]`);
|
||||||
|
if (calHost) {
|
||||||
|
const btn = calHost.querySelector('.ui-calendar-button') || calHost.querySelector('button');
|
||||||
|
btn?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDate(event: Event, filter: ActiveFilter): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
filter.value = filter.valueOperator === 'range' ? null : null;
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAvailableFilters(): void {
|
||||||
|
const activeKeys = new Set(this.activeFilters.map(f => f.definition.key));
|
||||||
|
this.availableFilters = [
|
||||||
|
{ label: '-- Select Filter --', value: null },
|
||||||
|
...this.filterDefinitions
|
||||||
|
.filter(f => !activeKeys.has(f.key))
|
||||||
|
.map(f => ({ label: f.label, value: f.key }))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitChange(): void {
|
||||||
|
const event: FilterChangeEvent = {
|
||||||
|
filters: [...this.activeFilters],
|
||||||
|
query: buildFilterQuery(this.activeFilters)
|
||||||
|
};
|
||||||
|
if (this.autoSaveOnChange) { this.saveState(); }
|
||||||
|
this.filtersChanged.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultValue(def: FilterDefinition, op: ValueOperator): any {
|
||||||
|
switch (def.dataType) {
|
||||||
|
case 'text': return '';
|
||||||
|
case 'number': return null;
|
||||||
|
case 'select': return null;
|
||||||
|
case 'select-multi': return [];
|
||||||
|
case 'date': return op === 'range' ? null : null;
|
||||||
|
case 'date-preset': return '1m';
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveState(): void {
|
||||||
|
if (!this.stateKey) { return; }
|
||||||
|
const state = this.activeFilters.map(f => ({
|
||||||
|
key: f.definition.key,
|
||||||
|
value: f.value,
|
||||||
|
operator: f.operator,
|
||||||
|
valueOperator: f.valueOperator,
|
||||||
|
datePreset: this.datePresetSelected.get(f.id) || null
|
||||||
|
}));
|
||||||
|
sessionStorage.setItem(this.stateKey, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearState(): void {
|
||||||
|
if (!this.stateKey) { return; }
|
||||||
|
sessionStorage.removeItem(this.stateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDefaultFilters(): void {
|
||||||
|
if (!this.defaultFilters?.length) { return; }
|
||||||
|
for (const df of this.defaultFilters) {
|
||||||
|
const def = this.filterDefinitions.find(f => f.key === df.key);
|
||||||
|
if (!def) { continue; }
|
||||||
|
const defaultOp = DEFAULT_VALUE_OPERATOR[def.dataType];
|
||||||
|
const filter: ActiveFilter = {
|
||||||
|
id: this.nextId++,
|
||||||
|
definition: def,
|
||||||
|
value: df.value,
|
||||||
|
operator: 'and',
|
||||||
|
valueOperator: defaultOp
|
||||||
|
};
|
||||||
|
this.activeFilters.push(filter);
|
||||||
|
if (def.dataType === 'date-preset') {
|
||||||
|
this.datePresetSelected.set(filter.id, df.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.activeFilters.length) {
|
||||||
|
this.buildAvailableFilters();
|
||||||
|
this.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreState(): void {
|
||||||
|
if (!this.stateKey) { return; }
|
||||||
|
this.stateRestored = true;
|
||||||
|
const raw = sessionStorage.getItem(this.stateKey);
|
||||||
|
if (!raw) {
|
||||||
|
this.applyDefaultFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved: any[];
|
||||||
|
try { saved = JSON.parse(raw); } catch {
|
||||||
|
this.applyDefaultFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(saved) || !saved.length) {
|
||||||
|
this.applyDefaultFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of saved) {
|
||||||
|
const def = this.filterDefinitions.find(f => f.key === entry.key);
|
||||||
|
if (!def) { continue; }
|
||||||
|
|
||||||
|
const filter: ActiveFilter = {
|
||||||
|
id: this.nextId++,
|
||||||
|
definition: def,
|
||||||
|
value: this.deserializeValue(entry.value, def.dataType, entry.valueOperator),
|
||||||
|
operator: entry.operator || 'and',
|
||||||
|
valueOperator: entry.valueOperator || DEFAULT_VALUE_OPERATOR[def.dataType]
|
||||||
|
};
|
||||||
|
this.activeFilters.push(filter);
|
||||||
|
|
||||||
|
if (def.dataType === 'date-preset' && entry.datePreset) {
|
||||||
|
this.datePresetSelected.set(filter.id, entry.datePreset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeFilters.length) {
|
||||||
|
this.buildAvailableFilters();
|
||||||
|
this.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deserializeValue(value: any, dataType: FilterDataType, valueOperator: string): any {
|
||||||
|
if (value == null) { return value; }
|
||||||
|
if (dataType === 'date' && valueOperator === 'range' && Array.isArray(value)) {
|
||||||
|
return value.map(v => v ? new Date(v) : null);
|
||||||
|
}
|
||||||
|
if (dataType === 'date' && typeof value === 'string') {
|
||||||
|
return new Date(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,9 @@
|
|||||||
|
.ui-g.ui-g-12 {
|
||||||
|
padding: 6px 0px 0px 0px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-width: 19rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ui-confirmdialog-message ul {
|
.ui-confirmdialog-message ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<div class="ui-g ui-g-12" style="padding: 6px 0px 0px 0px; margin-bottom: 0">
|
<div class="ui-g ui-g-12">
|
||||||
<div class="ui-g-3 ui-sm-12 ui-md-4 ui-lg-3 ui-xl-3" style="margin-top: 8px">
|
<div class="ui-g-3 ui-sm-12 ui-md-4 ui-lg-3 ui-xl-3" style="margin-top: 8px">
|
||||||
<div class="flex-row">
|
<div class="flex-row">
|
||||||
<div style="margin-bottom: 4px; display: inline-block;flex-grow: 0;">
|
<div style="margin-bottom: 4px; display: inline-block;flex-grow: 0;">
|
||||||
|
|||||||
@ -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 { DlqMonitorComponent } from './dlq-monitor.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: DlqMonitorComponent,
|
||||||
|
data: { roles: [RoleIds.ADMIN] },
|
||||||
|
canActivate: [AuthGuard, SettingsGuard]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule],
|
||||||
|
providers: [AuthGuard]
|
||||||
|
})
|
||||||
|
export class DlqMonitorRoutingModule { }
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
/* Stats row */
|
||||||
|
.dlq-stats-row {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlq-stat-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
margin: 0.25rem 0.25rem 0.25rem 0;
|
||||||
|
border-left: 4px solid #BDBDBD;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlq-stat-card.stat-success { border-left-color: #4caf50; }
|
||||||
|
.dlq-stat-card.stat-warning { border-left-color: #FFC107; }
|
||||||
|
.dlq-stat-card.stat-danger { border-left-color: #f44336; }
|
||||||
|
|
||||||
|
.dlq-stat-label {
|
||||||
|
font-size: 0.78em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #757575;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlq-stat-value {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #212121;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlq-stat-card.stat-success .dlq-stat-value { color: #2E7D32; }
|
||||||
|
.dlq-stat-card.stat-warning .dlq-stat-value { color: #FF8F00; }
|
||||||
|
.dlq-stat-card.stat-danger .dlq-stat-value { color: #f44336; }
|
||||||
|
|
||||||
|
.dlq-stat-sub {
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: #757575;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlq-stat-icon {
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages table */
|
||||||
|
.dlq-messages-table {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlq-msg-icon {
|
||||||
|
font-size: 0.9375rem !important;
|
||||||
|
color: #757575;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlq-error-icon {
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
color: #f44336;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlq-error-cell {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #c62828;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category badges — uses agm-badge base from global styles */
|
||||||
|
.dlq-category-badge {
|
||||||
|
font-size: 0.75em !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-transient { background: #03A9F4; color: #fff; }
|
||||||
|
.category-validation { background: #f44336; color: #fff; }
|
||||||
|
.category-processing { background: #FF9800; color: #fff; }
|
||||||
|
.category-infrastructure { background: #757575; color: #fff; }
|
||||||
|
.category-partner_api { background: #4527A0; color: #fff; }
|
||||||
|
.category-unknown { background: #9E9E9E; color: #fff; }
|
||||||
|
|
||||||
|
/* Purge dialog warning */
|
||||||
|
.dlq-purge-warning {
|
||||||
|
color: #b71c1c;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
<div class="ui-g">
|
||||||
|
<div class="ui-g-12">
|
||||||
|
<div class="card card-w-title">
|
||||||
|
<h1>Dead Letter Queue Monitor</h1>
|
||||||
|
|
||||||
|
<!-- Queue Selector & Last Updated -->
|
||||||
|
<div class="ui-g" style="align-items: center; margin-bottom: 8px;">
|
||||||
|
<div class="ui-g-12 ui-md-4 ui-lg-3 ui-g-nopad" style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<label style="font-weight:bold; white-space:nowrap;">Queue:</label>
|
||||||
|
<p-dropdown
|
||||||
|
[options]="queues"
|
||||||
|
[(ngModel)]="selectedQueue"
|
||||||
|
(onChange)="refreshAll()"
|
||||||
|
[style]="{'width':'220px'}">
|
||||||
|
</p-dropdown>
|
||||||
|
</div>
|
||||||
|
<div class="ui-g-12 ui-md-8 ui-lg-9" *ngIf="lastUpdated" style="text-align:right; color:#757575; font-size:0.85em; padding-top:4px;">
|
||||||
|
Last updated: {{ lastUpdated | date:'medium' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Row -->
|
||||||
|
<div class="ui-g dlq-stats-row">
|
||||||
|
<div class="ui-g-12 ui-sm-6 ui-md-3">
|
||||||
|
<div class="dlq-stat-card" [ngClass]="dlqStatusClass">
|
||||||
|
<div class="dlq-stat-label">DLQ Messages</div>
|
||||||
|
<div class="dlq-stat-value">{{ loadingStats ? '…' : dlqCount }}</div>
|
||||||
|
<div class="dlq-stat-sub">
|
||||||
|
<i class="material-icons dlq-stat-icon">{{ dlqCount >= 50 ? 'error' : dlqCount >= 20 ? 'warning' : 'check_circle' }}</i>
|
||||||
|
{{ loadingStats ? 'Loading…' : dlqStatusLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-g-12 ui-sm-6 ui-md-3">
|
||||||
|
<div class="dlq-stat-card">
|
||||||
|
<div class="dlq-stat-label">Retention Period</div>
|
||||||
|
<div class="dlq-stat-value">365</div>
|
||||||
|
<div class="dlq-stat-sub"><i class="material-icons dlq-stat-icon">schedule</i> days until auto-archive</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-g-12 ui-sm-6 ui-md-3">
|
||||||
|
<div class="dlq-stat-card">
|
||||||
|
<div class="dlq-stat-label">Alert Threshold</div>
|
||||||
|
<div class="dlq-stat-value">20</div>
|
||||||
|
<div class="dlq-stat-sub"><i class="material-icons dlq-stat-icon">notifications</i> messages before alert</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-g-12 ui-sm-6 ui-md-3">
|
||||||
|
<div class="dlq-stat-card">
|
||||||
|
<div class="dlq-stat-label">Consumers</div>
|
||||||
|
<div class="dlq-stat-value">{{ loadingStats ? '…' : consumerCount }}</div>
|
||||||
|
<div class="dlq-stat-sub"><i class="material-icons dlq-stat-icon">people</i> active</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages Table -->
|
||||||
|
<p-table #dt [value]="messages" [paginator]="true" [rows]="10" [rowsPerPageOptions]="[10, 20, 50]"
|
||||||
|
[alwaysShowPaginator]="true" [responsive]="true" styleClass="dlq-messages-table"
|
||||||
|
selectionMode="single" [(selection)]="selectedMessage">
|
||||||
|
<ng-template pTemplate="caption">
|
||||||
|
<div class="ui-g ui-g-nopad" style="align-items:center;">
|
||||||
|
<div class="ui-g-12 ui-g-nopad">
|
||||||
|
<span class="table-caption-1">Recent Messages</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Partner</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ui-fluid">
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, 'taskInfo.logFileName', 'contains')" placeholder="Filter...">
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="ui-fluid">
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, '_partnerCode', 'contains')" placeholder="Filter...">
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="ui-fluid">
|
||||||
|
<p-dropdown [options]="categoryFilterOptions" [(ngModel)]="categoryFilter"
|
||||||
|
(onChange)="dt.filter($event.value, '_errorCategory', 'equals')"
|
||||||
|
[showClear]="true" placeholder="All"></p-dropdown>
|
||||||
|
</th>
|
||||||
|
<th class="ui-fluid">
|
||||||
|
<p-dropdown [options]="severityFilterOptions" [(ngModel)]="severityFilter"
|
||||||
|
(onChange)="dt.filter($event.value, '_severity', 'equals')"
|
||||||
|
[showClear]="true" placeholder="All"></p-dropdown>
|
||||||
|
</th>
|
||||||
|
<th class="ui-fluid">
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<i class="ui-icon-search"></i>
|
||||||
|
<input pInputText type="text" (input)="dt.filter($event.target.value, 'errorMessage', 'contains')" placeholder="Filter...">
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="body" let-msg>
|
||||||
|
<tr [pSelectableRow]="msg">
|
||||||
|
<td>
|
||||||
|
<span class="ui-column-title">File</span>
|
||||||
|
<i class="material-icons dlq-msg-icon">description</i>
|
||||||
|
{{ msg.taskInfo?.logFileName || 'Unknown' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="ui-column-title">Partner</span>
|
||||||
|
{{ msg._partnerCode || 'N/A' }}
|
||||||
|
</td>
|
||||||
|
<td class="table-col-center">
|
||||||
|
<span class="ui-column-title">Category</span>
|
||||||
|
<span class="agm-badge dlq-category-badge" [ngClass]="getCategoryClass(msg)">
|
||||||
|
{{ msg._errorCategory }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="ui-column-title">Severity</span>
|
||||||
|
{{ msg._severity }}
|
||||||
|
</td>
|
||||||
|
<td class="dlq-error-cell">
|
||||||
|
<span class="ui-column-title">Error</span>
|
||||||
|
<span *ngIf="msg.errorMessage" title="{{ msg.errorMessage }}">
|
||||||
|
<i class="material-icons dlq-error-icon">error_outline</i>
|
||||||
|
{{ msg.errorMessage | slice:0:80 }}{{ msg.errorMessage.length > 80 ? '…' : '' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="emptymessage">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="text-align:center; color:#757575; padding:1.5rem;">
|
||||||
|
<i class="material-icons" style="vertical-align:middle; margin-right:0.25rem;">inbox</i> No messages in DLQ.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="paginatorleft" let-state>
|
||||||
|
{{ state.totalRecords }} message{{ state.totalRecords !== 1 ? 's' : '' }}
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
|
||||||
|
<!-- Queue Operations Toolbar -->
|
||||||
|
<div class="ui-widget-header ui-helper-clearfix toolbar">
|
||||||
|
<button pButton type="button" icon="ui-icon-refresh" label="Refresh" class="blue-btn" (click)="refreshAll()"></button>
|
||||||
|
<button pButton type="button" icon="ui-icon-redo" label="Retry Message" class="green-btn" [disabled]="!selectedMessage" (click)="retrySelected()"></button>
|
||||||
|
<button pButton type="button" icon="ui-icon-redo" label="Retry All" class="green-btn" (click)="retryAll()"></button>
|
||||||
|
<button pButton type="button" icon="ui-icon-label" label="Retry by Header" class="green-btn" (click)="openRetryByHeaderDialog()"></button>
|
||||||
|
<button pButton type="button" icon="ui-icon-flash-on" label="Auto-Process" class="green-btn" (click)="processDLQ()"></button>
|
||||||
|
<button pButton type="button" icon="ui-icon-delete-forever" label="Purge" class="green-btn" (click)="openPurgeDialog()"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Retry by Header Dialog -->
|
||||||
|
<p-dialog
|
||||||
|
header="Retry by Header"
|
||||||
|
[(visible)]="showRetryByHeaderDialog"
|
||||||
|
[modal]="true"
|
||||||
|
[style]="{width:'440px'}"
|
||||||
|
[closable]="true">
|
||||||
|
<div class="ui-g ui-g-fluid">
|
||||||
|
<div class="ui-g-12">
|
||||||
|
<label for="header-name">Header name <small>(e.g. x-partner-code)</small>:</label>
|
||||||
|
<input id="header-name" type="text" pInputText [(ngModel)]="headerName" style="width:100%;margin-top:4px;" placeholder="x-partner-code" />
|
||||||
|
</div>
|
||||||
|
<div class="ui-g-12" style="margin-top:12px;">
|
||||||
|
<label for="header-value">Header value <small>(e.g. SATLOC)</small>:</label>
|
||||||
|
<input id="header-value" type="text" pInputText [(ngModel)]="headerValue" style="width:100%;margin-top:4px;" placeholder="SATLOC" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p-footer>
|
||||||
|
<button pButton type="button" icon="ui-icon-close" label="Cancel" (click)="showRetryByHeaderDialog = false"></button>
|
||||||
|
<button pButton type="button" icon="ui-icon-redo" label="Retry" class="green-btn"
|
||||||
|
[disabled]="!headerName.trim() || !headerValue.trim()" (click)="submitRetryByHeader()"></button>
|
||||||
|
</p-footer>
|
||||||
|
</p-dialog>
|
||||||
|
|
||||||
|
<!-- Purge Confirmation Dialog -->
|
||||||
|
<p-dialog
|
||||||
|
header="Purge DLQ"
|
||||||
|
[(visible)]="showPurgeDialog"
|
||||||
|
[modal]="true"
|
||||||
|
[style]="{width:'460px'}"
|
||||||
|
[closable]="true">
|
||||||
|
<div class="ui-g ui-g-fluid">
|
||||||
|
<div class="ui-g-12">
|
||||||
|
<p class="dlq-purge-warning">
|
||||||
|
<i class="material-icons" style="vertical-align:middle; margin-right:4px;">warning</i>
|
||||||
|
This will permanently delete <strong>ALL</strong> messages from the queue.
|
||||||
|
Type <strong>PURGE</strong> to confirm.
|
||||||
|
</p>
|
||||||
|
<input type="text" pInputText [(ngModel)]="purgeConfirmText" style="width:100%;margin-top:4px;" placeholder="Type PURGE to confirm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p-footer>
|
||||||
|
<button pButton type="button" icon="ui-icon-close" label="Cancel" (click)="showPurgeDialog = false"></button>
|
||||||
|
<button pButton type="button" icon="ui-icon-delete-forever" label="Purge" class="orange-btn"
|
||||||
|
[disabled]="purgeConfirmText !== 'PURGE'" (click)="submitPurge()"></button>
|
||||||
|
</p-footer>
|
||||||
|
</p-dialog>
|
||||||
|
|
||||||
@ -0,0 +1,231 @@
|
|||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { SelectItem } from 'primeng/api';
|
||||||
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
|
import { DlqMonitorService, DlqStats, DlqMessage } from './dlq-monitor.service';
|
||||||
|
|
||||||
|
export interface DlqMessageRow extends DlqMessage {
|
||||||
|
_partnerCode: string;
|
||||||
|
_errorCategory: string;
|
||||||
|
_severity: string;
|
||||||
|
_queuePosition: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'agm-dlq-monitor',
|
||||||
|
templateUrl: './dlq-monitor.component.html',
|
||||||
|
styleUrls: ['./dlq-monitor.component.css']
|
||||||
|
})
|
||||||
|
export class DlqMonitorComponent extends BaseComp implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
queues: SelectItem[] = [
|
||||||
|
{ label: 'dev_partner_tasks', value: 'dev_partner_tasks' },
|
||||||
|
{ label: 'partner_tasks', value: 'partner_tasks' }
|
||||||
|
];
|
||||||
|
selectedQueue = 'dev_partner_tasks';
|
||||||
|
|
||||||
|
stats: DlqStats | null = null;
|
||||||
|
messages: DlqMessageRow[] = [];
|
||||||
|
lastUpdated: Date | null = null;
|
||||||
|
loadingStats = false;
|
||||||
|
loadingMessages = false;
|
||||||
|
|
||||||
|
// Table selection & filter state
|
||||||
|
selectedMessage: DlqMessageRow | null = null;
|
||||||
|
categoryFilter: string = null;
|
||||||
|
severityFilter: string = null;
|
||||||
|
|
||||||
|
categoryFilterOptions: SelectItem[] = [
|
||||||
|
{ label: 'transient', value: 'transient' },
|
||||||
|
{ label: 'validation', value: 'validation' },
|
||||||
|
{ label: 'processing', value: 'processing' },
|
||||||
|
{ label: 'infrastructure', value: 'infrastructure' },
|
||||||
|
{ label: 'partner_api', value: 'partner_api' },
|
||||||
|
{ label: 'unknown', value: 'unknown' },
|
||||||
|
];
|
||||||
|
|
||||||
|
severityFilterOptions: SelectItem[] = [
|
||||||
|
{ label: 'low', value: 'low' },
|
||||||
|
{ label: 'medium', value: 'medium' },
|
||||||
|
{ label: 'high', value: 'high' },
|
||||||
|
{ label: 'critical', value: 'critical' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Retry by header dialog
|
||||||
|
showRetryByHeaderDialog = false;
|
||||||
|
headerName = '';
|
||||||
|
headerValue = '';
|
||||||
|
|
||||||
|
// Purge dialog
|
||||||
|
showPurgeDialog = false;
|
||||||
|
purgeConfirmText = '';
|
||||||
|
|
||||||
|
private refreshInterval: any;
|
||||||
|
|
||||||
|
constructor(private readonly dlqSvc: DlqMonitorService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.refreshAll();
|
||||||
|
this.refreshInterval = setInterval(() => this.refreshAll(), 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get dlqCount(): number {
|
||||||
|
return this.stats?.dlq?.messageCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get consumerCount(): number {
|
||||||
|
return this.stats?.dlq?.consumerCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get dlqStatusLabel(): string {
|
||||||
|
if (this.dlqCount >= 50) return 'CRITICAL';
|
||||||
|
if (this.dlqCount >= 20) return 'WARNING';
|
||||||
|
return 'Normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
get dlqStatusClass(): string {
|
||||||
|
if (this.dlqCount >= 50) return 'stat-danger';
|
||||||
|
if (this.dlqCount >= 20) return 'stat-warning';
|
||||||
|
return 'stat-success';
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAll(): void {
|
||||||
|
this.loadStats();
|
||||||
|
this.loadMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadStats(): void {
|
||||||
|
this.loadingStats = true;
|
||||||
|
this.dlqSvc.getStats(this.selectedQueue).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.stats = data;
|
||||||
|
this.loadingStats = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.msgSvc.addFailedMsg('Failed to load stats: ' + (err?.error?.error?.message || err.message));
|
||||||
|
this.loadingStats = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadMessages(): void {
|
||||||
|
this.loadingMessages = true;
|
||||||
|
this.dlqSvc.getMessages(this.selectedQueue, 20).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.messages = (data.messages || []).map((msg, index) => ({
|
||||||
|
...msg,
|
||||||
|
_partnerCode: (msg.headers && msg.headers['x-partner-code']) || '',
|
||||||
|
_errorCategory: (msg.headers && msg.headers['x-error-category']) || 'unknown',
|
||||||
|
_severity: (msg.headers && msg.headers['x-severity']) || 'low',
|
||||||
|
_queuePosition: index,
|
||||||
|
}));
|
||||||
|
this.selectedMessage = null;
|
||||||
|
this.lastUpdated = new Date();
|
||||||
|
this.loadingMessages = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.msgSvc.addFailedMsg('Failed to load messages: ' + (err?.error?.error?.message || err.message));
|
||||||
|
this.loadingMessages = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAll(): void {
|
||||||
|
this.confirmSvc.confirm({
|
||||||
|
message: 'Retry all DLQ messages?',
|
||||||
|
header: 'Confirm Retry All',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
accept: () => {
|
||||||
|
this.dlqSvc.retryAll(this.selectedQueue).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.msgSvc.addSuccessMsg(`Retried ${data.retriedCount} messages!`);
|
||||||
|
this.refreshAll();
|
||||||
|
},
|
||||||
|
error: (err) => this.msgSvc.addFailedMsg('Failed to retry: ' + (err?.error?.error?.message || err.message))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retrySelected(): void {
|
||||||
|
if (!this.selectedMessage) { return; }
|
||||||
|
this.retryByPosition(this.selectedMessage._queuePosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
retryByPosition(position: number): void {
|
||||||
|
this.dlqSvc.retryByPosition(this.selectedQueue, position).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.msgSvc.addSuccessMsg(`Retried message at position ${position}!`);
|
||||||
|
this.refreshAll();
|
||||||
|
},
|
||||||
|
error: (err) => this.msgSvc.addFailedMsg('Failed to retry: ' + (err?.error?.error?.message || err.message))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openRetryByHeaderDialog(): void {
|
||||||
|
this.headerName = '';
|
||||||
|
this.headerValue = '';
|
||||||
|
this.showRetryByHeaderDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitRetryByHeader(): void {
|
||||||
|
if (!this.headerName.trim() || !this.headerValue.trim()) { return; }
|
||||||
|
this.showRetryByHeaderDialog = false;
|
||||||
|
this.dlqSvc.retryByHeader(this.selectedQueue, this.headerName.trim(), this.headerValue.trim()).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.msgSvc.addSuccessMsg(`Retried ${data.retriedCount} messages!`);
|
||||||
|
this.refreshAll();
|
||||||
|
},
|
||||||
|
error: (err) => this.msgSvc.addFailedMsg('Failed to retry by header: ' + (err?.error?.error?.message || err.message))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
processDLQ(): void {
|
||||||
|
this.confirmSvc.confirm({
|
||||||
|
message: 'Auto-process DLQ? This will categorize errors and retry/archive messages.',
|
||||||
|
header: 'Confirm Auto-Process',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
accept: () => {
|
||||||
|
this.dlqSvc.processDLQ(this.selectedQueue).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.msgSvc.addSuccessMsg(`Processed ${data.processed}: ${data.retried} retried, ${data.archived} archived`);
|
||||||
|
this.refreshAll();
|
||||||
|
},
|
||||||
|
error: (err) => this.msgSvc.addFailedMsg('Failed to process: ' + (err?.error?.error?.message || err.message))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openPurgeDialog(): void {
|
||||||
|
this.purgeConfirmText = '';
|
||||||
|
this.showPurgeDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitPurge(): void {
|
||||||
|
if (this.purgeConfirmText !== 'PURGE') { return; }
|
||||||
|
this.showPurgeDialog = false;
|
||||||
|
this.dlqSvc.purgeDLQ(this.selectedQueue).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.msgSvc.addSuccessMsg(`Purged ${data.purgedCount} messages`);
|
||||||
|
this.refreshAll();
|
||||||
|
},
|
||||||
|
error: (err) => this.msgSvc.addFailedMsg('Failed to purge: ' + (err?.error?.error?.message || err.message))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMsgHeader(msg: DlqMessage, key: string): string {
|
||||||
|
return (msg.headers && msg.headers[key]) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryClass(msg: DlqMessageRow): string {
|
||||||
|
return 'category-' + (msg._errorCategory || 'unknown').replace(/[^a-z0-9_]/gi, '_').toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { DialogModule } from 'primeng-lts/dialog';
|
||||||
|
import { ConfirmDialogModule } from 'primeng-lts/confirmdialog';
|
||||||
|
import { ProgressSpinnerModule } from 'primeng-lts/progressspinner';
|
||||||
|
import { TableModule } from 'primeng-lts/table';
|
||||||
|
|
||||||
|
import { AppSharedModule } from '../../shared/app-shared.module';
|
||||||
|
import { DlqMonitorRoutingModule } from './dlq-monitor-routing.module';
|
||||||
|
import { DlqMonitorComponent } from './dlq-monitor.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
AppSharedModule,
|
||||||
|
DialogModule,
|
||||||
|
ConfirmDialogModule,
|
||||||
|
ProgressSpinnerModule,
|
||||||
|
TableModule,
|
||||||
|
DlqMonitorRoutingModule
|
||||||
|
],
|
||||||
|
declarations: [DlqMonitorComponent]
|
||||||
|
})
|
||||||
|
export class DlqMonitorModule { }
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface DlqStats {
|
||||||
|
dlq: {
|
||||||
|
messageCount: number;
|
||||||
|
consumerCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DlqMessage {
|
||||||
|
taskInfo?: { logFileName?: string };
|
||||||
|
headers?: { [key: string]: string };
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DlqMessagesResponse {
|
||||||
|
messages: DlqMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class DlqMonitorService {
|
||||||
|
private readonly base = '/dlq';
|
||||||
|
|
||||||
|
constructor(private readonly http: HttpClient) {}
|
||||||
|
|
||||||
|
getStats(queue: string): Observable<DlqStats> {
|
||||||
|
return this.http.get<DlqStats>(`${this.base}/${queue}/stats`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessages(queue: string, limit = 20): Observable<DlqMessagesResponse> {
|
||||||
|
return this.http.get<DlqMessagesResponse>(`${this.base}/${queue}/messages?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAll(queue: string): Observable<{ retriedCount: number }> {
|
||||||
|
return this.http.post<{ retriedCount: number }>(`${this.base}/${queue}/retryAll`, { maxMessages: 1000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
retryByPosition(queue: string, position: number): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/${queue}/retryByPosition`, { position });
|
||||||
|
}
|
||||||
|
|
||||||
|
retryByHeader(queue: string, headerName: string, headerValue: string): Observable<{ retriedCount: number }> {
|
||||||
|
return this.http.post<{ retriedCount: number }>(`${this.base}/${queue}/retryByHeader`, { headerName, headerValue, maxMessages: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
processDLQ(queue: string): Observable<{ processed: number; retried: number; archived: number }> {
|
||||||
|
return this.http.post<{ processed: number; retried: number; archived: number }>(`${this.base}/${queue}/process`, { maxMessages: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
purgeDLQ(queue: string): Observable<{ purgedCount: number }> {
|
||||||
|
return this.http.request<{ purgedCount: number }>('DELETE', `${this.base}/${queue}/purge`, { body: { confirm: true } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ export class ToolsCanactiveGuard implements CanActivate {
|
|||||||
// TODO: find a way to re-use this logic across multiple feature modules
|
// TODO: find a way to re-use this logic across multiple feature modules
|
||||||
|
|
||||||
// Make sure clients loaded first
|
// Make sure clients loaded first
|
||||||
|
if (this.authSvc.isAdmin) return of(true);
|
||||||
if (this.authSvc.isClientUser) return of(true);
|
if (this.authSvc.isClientUser) return of(true);
|
||||||
|
|
||||||
return forkJoin(
|
return forkJoin(
|
||||||
|
|||||||
@ -14,12 +14,13 @@ import { SettingsComponent } from './settings/settings.component';
|
|||||||
import { SettingsGuard } from '../domain/guards/settings-guard.service';
|
import { SettingsGuard } from '../domain/guards/settings-guard.service';
|
||||||
import { GMapLoadGuard } from '../domain/guards/gmap-load.guard';
|
import { GMapLoadGuard } from '../domain/guards/gmap-load.guard';
|
||||||
|
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ToolsMgtComponent,
|
component: ToolsMgtComponent,
|
||||||
data: {
|
data: {
|
||||||
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]
|
roles: [RoleIds.ADMIN, RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]
|
||||||
},
|
},
|
||||||
canActivate: [AuthGuard, SettingsGuard, ToolsCanactiveGuard],
|
canActivate: [AuthGuard, SettingsGuard, ToolsCanactiveGuard],
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
.ui-g-12.ui-md-12.ui-lg-11.ui-xl-11 {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
@ -2,6 +2,60 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-g.ui-g-12 {
|
||||||
|
min-width: 19rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 40em) {
|
||||||
|
.left-panel-wrapper {
|
||||||
|
position: static !important;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel-wrapper .left-panel {
|
||||||
|
position: relative;
|
||||||
|
width: 100% !important;
|
||||||
|
left: auto !important;
|
||||||
|
height: 40vh;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel-wrapper .panel-content {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel-wrapper .resize-handle-right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel-wrapper .handle-right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-bottom {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-bottom::after {
|
||||||
|
content : "\25B2\FE0E \25BC\FE0E";
|
||||||
|
position : absolute;
|
||||||
|
left : 50%;
|
||||||
|
top : 50%;
|
||||||
|
transform : translate(-50%, -50%);
|
||||||
|
font-size : .65em;
|
||||||
|
color : rgb(100, 100, 100);
|
||||||
|
pointer-events : none;
|
||||||
|
line-height : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 40.063em) {
|
||||||
|
.resize-handle-bottom {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.resize-handle-right,
|
.resize-handle-right,
|
||||||
.resize-handle-top {
|
.resize-handle-top {
|
||||||
position : absolute;
|
position : absolute;
|
||||||
@ -24,6 +78,16 @@
|
|||||||
cursor: row-resize;
|
cursor: row-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resize-handle-bottom {
|
||||||
|
position : absolute;
|
||||||
|
background-color: #e6eee6;
|
||||||
|
height : 10px;
|
||||||
|
width : 100%;
|
||||||
|
bottom : 0;
|
||||||
|
left : 0;
|
||||||
|
cursor : row-resize;
|
||||||
|
}
|
||||||
|
|
||||||
.left-panel,
|
.left-panel,
|
||||||
.bottom-panel {
|
.bottom-panel {
|
||||||
z-index : 1001;
|
z-index : 1001;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<div class="ui-g ui-g-12" style="padding: 6px 0px 0px 0px; margin-bottom: 0">
|
<div class="ui-g ui-g-12" style="padding: 6px 0px 0px 0px; margin-bottom: 0">
|
||||||
<div class="ui-g-4 ui-sm-12 ui-md-4 ui-lg-3 ui-xl-3" style="margin-top:42px; position:absolute; padding: 0;">
|
<div class="left-panel-wrapper ui-g-4 ui-sm-12 ui-md-4 ui-lg-3 ui-xl-3" style="margin-top:42px; position:absolute; padding: 0;">
|
||||||
<!-- Resizeable left panel -->
|
<!-- Resizeable left panel -->
|
||||||
<div #leftPanel class="left-panel" [ngStyle]="leftPStyle" mwlResizable [mouseMoveThrottleMS]="50" [validateResize]="validatePLeft" [enableGhostResize]="true" (resizeEnd)="onLeftPResizeEnd($event)">
|
<div #leftPanel class="left-panel" [ngStyle]="leftPStyle" mwlResizable [mouseMoveThrottleMS]="50" [validateResize]="validatePLeft" [enableGhostResize]="true" (resizeEnd)="onLeftPResizeEnd($event)">
|
||||||
<div #leftPContent class="panel-content">
|
<div #leftPContent class="panel-content">
|
||||||
@ -198,6 +198,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="resize-handle-bottom" mwlResizeHandle [resizeEdges]="{ bottom: true }"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -486,6 +486,26 @@ export class TrackComponent extends MapBaseComp implements OnInit, AfterViewInit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostListener('window:resize', ['$event'])
|
||||||
|
resizeEvent(e: any) {
|
||||||
|
this.resetLeftPStyleForWidth();
|
||||||
|
this.updateMapSize(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetLeftPStyleForWidth() {
|
||||||
|
if (window.innerWidth <= 640) {
|
||||||
|
if (this.leftPStyle && this.leftPStyle['position'] === 'absolute') {
|
||||||
|
const h = this.leftPStyle['height'];
|
||||||
|
this.leftPStyle = h ? { height: h } : null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!this.leftPStyle || this.leftPStyle['position'] !== 'absolute') {
|
||||||
|
const w = this.panelState.left && this.panelState.left.width ? this.panelState.left.width : MAX_LP_WIDTH_PX;
|
||||||
|
this.leftPStyle = { position: 'absolute', left: '0px', width: `${w}px` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('window:keyup', ['$event'])
|
@HostListener('window:keyup', ['$event'])
|
||||||
keyEvent(e: KeyboardEvent) {
|
keyEvent(e: KeyboardEvent) {
|
||||||
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === "h") {
|
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === "h") {
|
||||||
@ -1046,6 +1066,12 @@ export class TrackComponent extends MapBaseComp implements OnInit, AfterViewInit
|
|||||||
|
|
||||||
/* Handle Dynamic UI resizing */
|
/* Handle Dynamic UI resizing */
|
||||||
validatePLeft(e) {
|
validatePLeft(e) {
|
||||||
|
if (window.innerWidth <= 640) {
|
||||||
|
// Small screen: only validate height (bottom-edge drag)
|
||||||
|
if (e.rectangle.height && e.rectangle.height < MIN_PANEL_PX)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
e.rectangle.width && e.rectangle.height &&
|
e.rectangle.width && e.rectangle.height &&
|
||||||
(e.rectangle.width < MIN_PANEL_PX || e.rectangle.right > MAX_LP_WIDTH_PX
|
(e.rectangle.width < MIN_PANEL_PX || e.rectangle.right > MAX_LP_WIDTH_PX
|
||||||
@ -1056,12 +1082,21 @@ export class TrackComponent extends MapBaseComp implements OnInit, AfterViewInit
|
|||||||
}
|
}
|
||||||
onLeftPResizeEnd(e) {
|
onLeftPResizeEnd(e) {
|
||||||
this.zone.runOutsideAngular(() => {
|
this.zone.runOutsideAngular(() => {
|
||||||
this.leftPStyle = {
|
if (window.innerWidth <= 640) {
|
||||||
position: 'absolute',
|
// Small screen: apply height from bottom-edge drag
|
||||||
left: `${e.rectangle.left}px`,
|
if (e.rectangle.height) {
|
||||||
width: `${e.rectangle.width}px`,
|
this.leftPStyle = {
|
||||||
};
|
height: `${e.rectangle.height}px`,
|
||||||
this.panelState.left.width = e.rectangle.width;
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.leftPStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${e.rectangle.left}px`,
|
||||||
|
width: `${e.rectangle.width}px`,
|
||||||
|
};
|
||||||
|
this.panelState.left.width = e.rectangle.width;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
onLeftPToggle(e: Event) {
|
onLeftPToggle(e: Event) {
|
||||||
|
|||||||
@ -12,6 +12,10 @@ body {
|
|||||||
letter-spacing: unset;
|
letter-spacing: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
min-width: 19rem;
|
||||||
|
}
|
||||||
|
|
||||||
// Avoid unneeded outline for links
|
// Avoid unneeded outline for links
|
||||||
a:focus {
|
a:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@ -346,6 +350,55 @@ body .layout-container .topbar {
|
|||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always show a consistent hamburger button; never the apps/squares icon
|
||||||
|
.layout-container {
|
||||||
|
// Always show #topbar-menu-button, locked to 36px regardless of breakpoint
|
||||||
|
.topbar .topbar-right #topbar-menu-button {
|
||||||
|
display: block !important;
|
||||||
|
margin-left: 1rem;
|
||||||
|
i {
|
||||||
|
font-size: 36px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the notification badge on the hamburger button
|
||||||
|
.topbar .topbar-right #topbar-menu-button .topbar-badge {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// At wide screens the theme shows topbar-items inline (apps/squares icon).
|
||||||
|
// Override: hide it by default and only show it when activated by the hamburger,
|
||||||
|
// using the same dropdown behaviour as narrow screens.
|
||||||
|
.topbar-items {
|
||||||
|
float: none !important;
|
||||||
|
display: none !important;
|
||||||
|
position: absolute;
|
||||||
|
top: 75px;
|
||||||
|
right: 15px;
|
||||||
|
width: 275px;
|
||||||
|
|
||||||
|
&.topbar-items-visible {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the panel is open, skip the intermediate apps/squares click:
|
||||||
|
// hide the clickable row header and always show the submenu links directly.
|
||||||
|
.topbar-items.topbar-items-visible .profile-item {
|
||||||
|
> a {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
> ul.ultima-menu {
|
||||||
|
display: block !important;
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body .ui-growl {
|
body .ui-growl {
|
||||||
top: 120px;
|
top: 120px;
|
||||||
z-index: 2021;
|
z-index: 2021;
|
||||||
@ -695,6 +748,11 @@ body .slim-popup .leaflet-popup-content {
|
|||||||
border: green solid 1px;
|
border: green solid 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agm-accordion .ui-accordion-header.ui-state-default.ui-corner-all.ui-state-active {
|
||||||
|
background-color: white;
|
||||||
|
border: green solid 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.slim-accordion.ui-accordion .ui-accordion-header>a {
|
.slim-accordion.ui-accordion .ui-accordion-header>a {
|
||||||
padding: .1em 1em;
|
padding: .1em 1em;
|
||||||
}
|
}
|
||||||
@ -704,6 +762,11 @@ body .slim-popup .leaflet-popup-content {
|
|||||||
color: darkgreen;
|
color: darkgreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agm-accordion.ui-accordion .ui-accordion-header.ui-state-active,
|
||||||
|
.agm-accordion.ui-accordion .ui-accordion-header.ui-state-active>a {
|
||||||
|
color: darkgreen;
|
||||||
|
}
|
||||||
|
|
||||||
.ui-accordion.ui-accordion .ui-state-active .pi,
|
.ui-accordion.ui-accordion .ui-state-active .pi,
|
||||||
.ui-accordion.ui-accordion .ui-state-highlight .pi {
|
.ui-accordion.ui-accordion .ui-state-highlight .pi {
|
||||||
color: unset;
|
color: unset;
|
||||||
@ -1523,8 +1586,8 @@ body .ui-table .ui-table-tbody>tr>td {
|
|||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// EXPIRY WARNING — RESPONSIVE PLACEMENT
|
// EXPIRY WARNING — RESPONSIVE PLACEMENT
|
||||||
// At >1024px: shown in topbar (account-summary-info), content banner hidden
|
// At >640px: shown inline in topbar (account-summary-info)
|
||||||
// At ≤1024px: hidden in topbar (too narrow), shown as sticky bar below toolbar
|
// At ≤640px: hidden entirely (screen too narrow)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Global expiry-warning pill styles (used in topbar and content banner)
|
// Global expiry-warning pill styles (used in topbar and content banner)
|
||||||
@ -1560,34 +1623,141 @@ body .ui-table .ui-table-tbody>tr>td {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Left-side menu tab — hidden by default, shown only on mobile via media query below
|
||||||
|
#mobile-menu-tab {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 28px;
|
||||||
|
height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 101;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ffffff;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #2E7D32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User info block inside the left panel — only visible on small screens
|
||||||
|
.menu-user-info {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
// Hide topbar warning on small screens
|
// Hide the yellow-arrow button; replaced by the left-side tab
|
||||||
|
.layout-container .topbar .topbar-right #menu-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the left-side menu tab
|
||||||
|
#mobile-menu-tab {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
// Shrink logo panel so topbar-right has enough room
|
||||||
|
.layout-container .topbar {
|
||||||
|
.topbar-left {
|
||||||
|
width: 60px;
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
.topbar-right {
|
||||||
|
width: calc(100% - 60px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide user info text and expiry warning in the topbar on small screens
|
||||||
|
.topbar-right .account-summary-info,
|
||||||
.topbar-right .expiry-warning {
|
.topbar-right .expiry-warning {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sticky banner in normal flow — sits just below topbar, content scrolls under it
|
// Show expiry warning as a fixed full-width banner just below the topbar
|
||||||
.content-expiry-banner {
|
.content-expiry-banner {
|
||||||
display: flex;
|
display: flex !important;
|
||||||
justify-content: flex-end;
|
position: fixed;
|
||||||
position: sticky;
|
top: 75px;
|
||||||
top: 80px;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
z-index: 1999;
|
z-index: 1999;
|
||||||
|
justify-content: center;
|
||||||
.account-summary-info {
|
pointer-events: none;
|
||||||
padding-top: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-warning {
|
.expiry-warning {
|
||||||
display: inline-block;
|
display: block !important;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
padding: 6px 10px;
|
padding: 6px 12px;
|
||||||
margin-top: 0;
|
margin: 0;
|
||||||
// margin-right: 6px;
|
border-radius: 0;
|
||||||
border-radius: 0 0 0 4px;
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-break: break-word;
|
word-break: normal;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push layout-main down to make room for the banner
|
||||||
|
.layout-container:has(.content-expiry-banner) .layout-main {
|
||||||
|
padding-top: 111px; // 75px topbar + ~36px banner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show user info (without warning) at the top of the left panel
|
||||||
|
.menu-user-info {
|
||||||
|
display: block;
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background-color: #2E7D32;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.account-summary-info {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-username {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-type {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-contact {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide warning inside panel — it's shown in the banner instead
|
||||||
|
.expiry-warning {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1686,6 +1856,21 @@ body .ui-table .ui-table-tbody>tr>td {
|
|||||||
// Pattern matches PrimeNG's ui-datatable-stacked responsive behavior
|
// Pattern matches PrimeNG's ui-datatable-stacked responsive behavior
|
||||||
// Headers (ui-column-title) left-aligned, values flow naturally after
|
// Headers (ui-column-title) left-aligned, values flow naturally after
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll container: prevents table overflow; enforces a minimum usable width
|
||||||
|
p-table {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .ui-table {
|
||||||
|
min-width: 16.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
body .ui-table.ui-table-responsive {
|
body .ui-table.ui-table-responsive {
|
||||||
|
|
||||||
@ -1720,6 +1905,7 @@ body .ui-table .ui-table-tbody>tr>td {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SHARED CONSTRAINT MESSAGE COMPONENT SYSTEM
|
// SHARED CONSTRAINT MESSAGE COMPONENT SYSTEM
|
||||||
// AgMission Project Color Palette Compliance
|
// AgMission Project Color Palette Compliance
|
||||||
|
|||||||
BIN
Development/libs/phantomjs
Executable file
BIN
Development/libs/phantomjs
Executable file
Binary file not shown.
Binary file not shown.
@ -511,3 +511,32 @@ require('dotenv').config({ path: envPath });
|
|||||||
- Keep README files synchronized with actual implementation
|
- Keep README files synchronized with actual implementation
|
||||||
|
|
||||||
**DLQ Testing**: Use `docs/Partner_DLQ_API.postman_collection.json` to test all 6 queue-native endpoints.
|
**DLQ Testing**: Use `docs/Partner_DLQ_API.postman_collection.json` to test all 6 queue-native endpoints.
|
||||||
|
|
||||||
|
## Mermaid Diagram Standards (v11.12.0 Compatibility)
|
||||||
|
|
||||||
|
When creating Mermaid diagrams in documentation, follow these rules to avoid v11.12.0 syntax errors:
|
||||||
|
|
||||||
|
### Forbidden Syntax (v11.12.0 does NOT support):
|
||||||
|
- ❌ HTML line breaks in node text: `A["Text<br/>on lines"]` → FAILS
|
||||||
|
- ❌ Escaped quotes: `A{\"Text\"}` → FAILS
|
||||||
|
- ❌ `note` blocks in stateDiagram → FAILS
|
||||||
|
- ❌ Complex HTML formatting in labels
|
||||||
|
- ❌ Angle brackets in unquoted text: `-->|Text <key>|` → FAILS
|
||||||
|
- ❌ Long multi-line text in single node
|
||||||
|
|
||||||
|
### Required Syntax (v11.12.0 compatible):
|
||||||
|
- ✅ Plain text: `A[Simple text]`
|
||||||
|
- ✅ Single-line labels: `A[Text here]`
|
||||||
|
- ✅ Minimal quoting: use double quotes only when needed
|
||||||
|
- ✅ Split complex info across multiple connected nodes
|
||||||
|
- ✅ Use separate table/bullets below diagram for details
|
||||||
|
- ✅ For line breaks: create separate nodes and edges
|
||||||
|
- ✅ In sequenceDiagram: `participant A as Simple Name` (no `<br/>`)
|
||||||
|
|
||||||
|
### Best Practices:
|
||||||
|
1. Keep node labels to single line
|
||||||
|
2. No HTML line breaks (`<br/>`) anywhere
|
||||||
|
3. Use plain text for transition labels
|
||||||
|
4. Wrap special chars in quotes only if needed
|
||||||
|
5. Test in mermaid.live before committing
|
||||||
|
6. Place detailed explanations in supporting text, not in diagram nodes
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
engine-strict=true
|
engine-strict=false
|
||||||
@ -1 +0,0 @@
|
|||||||
jobId,orderNumber,jobName,sessionId,fileName,pilotName,timestampUtc,gpsTime,lat,lon,utmX,utmY,alt_m,groundSpeed_ms,heading,crossTrackError_m,lockedLine,hdop,satsInView,correctionId,waasId,sprayStat,flowRateApplied_Lmin,flowRateRequired_Lmin,appRateRequired_Lha,appRateApplied_Lha,swathWidth_m,boomPressure_psi,sprayOnLag_s,sprayOffLag_s,pulsesPerLitre,windSpeed_ms,windDir_deg,temp_c,humidity_pct
|
|
||||||
|
@ -1 +0,0 @@
|
|||||||
jobId,orderNumber,jobName,sessionId,fileName,pilotName,timestampUtc,gpsTime,lat,lon,utmX,utmY,alt_m,groundSpeed_ms,heading,crossTrackError_m,lockedLine,hdop,satsInView,correctionId,waasId,sprayStat,flowRateApplied_Lmin,flowRateRequired_Lmin,appRateRequired_Lha,appRateApplied_Lha,swathWidth_m,boomPressure_psi,sprayOnLag_s,sprayOffLag_s,pulsesPerLitre,windSpeed_ms,windDir_deg,temp_c,humidity_pct
|
|
||||||
|
2
Development/server/.vscode/launch.json
vendored
2
Development/server/.vscode/launch.json
vendored
@ -22,7 +22,7 @@
|
|||||||
"DEBUG": "agm:*",
|
"DEBUG": "agm:*",
|
||||||
"PRODUCTION": "false"
|
"PRODUCTION": "false"
|
||||||
},
|
},
|
||||||
"program": "${workspaceFolder}/workers/importCustStripeSubs.js",
|
"program": "${workspaceFolder}/scripts/importCustStripeSubs.js",
|
||||||
"args": [
|
"args": [
|
||||||
"cus_SIX3z3yexFrh6q", //"cus_RyON6s93uk5Wxh", // Replace with your Stripe customer ID
|
"cus_SIX3z3yexFrh6q", //"cus_RyON6s93uk5Wxh", // Replace with your Stripe customer ID
|
||||||
//"--dry-run"
|
//"--dry-run"
|
||||||
|
|||||||
395
Development/server/controllers/api_export.js
Normal file
395
Development/server/controllers/api_export.js
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async Export controller — /api/v1/jobs/:jobId/export and /api/v1/exports/:exportId
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. POST /api/v1/jobs/:jobId/export → creates ExportJob record (status=pending),
|
||||||
|
* kicks off async generation, returns { exportId, status: 'pending' }.
|
||||||
|
* 2. GET /api/v1/exports/:exportId → poll status; when ready returns { status: 'ready', downloadUrl }.
|
||||||
|
* 3. GET /api/v1/exports/:exportId/download → streams the file, schedules cleanup.
|
||||||
|
*
|
||||||
|
* FE / integration notes:
|
||||||
|
* - For the daily 17:00 batch: POST export after previous day's jobs are confirmed sprayed,
|
||||||
|
* poll every 10–30 s, then download the CSV when ready.
|
||||||
|
* - interval param works identically to the records endpoint (GPS point thinning).
|
||||||
|
* - CSV has all raw trace fields + job/session header columns repeated per row for
|
||||||
|
* direct Power BI / data-warehouse import without joins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { Transform, pipeline } = require('stream');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const pipelineAsync = promisify(pipeline);
|
||||||
|
|
||||||
|
const ObjectId = require('mongodb').ObjectId;
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
|
const { Job, App, AppFile, AppDetail } = require('../model');
|
||||||
|
const ExportJob = require('../model/export_job');
|
||||||
|
const { AppParamError, AppAuthError } = require('../helpers/app_error');
|
||||||
|
const { Errors, HttpStatus, ExportUnits } = require('../helpers/constants');
|
||||||
|
const utils = require('../helpers/utils');
|
||||||
|
const env = require('../helpers/env');
|
||||||
|
|
||||||
|
const EXPORT_TTL_HOURS = parseInt(process.env.EXPORT_TTL_HOURS) || 24;
|
||||||
|
|
||||||
|
// Re-use the same helpers from api_pub (inline to avoid a shared helper module for now)
|
||||||
|
function parseInterval(raw) {
|
||||||
|
if (raw == null || raw === '') return null;
|
||||||
|
const v = parseFloat(raw);
|
||||||
|
return isFinite(v) && v > 0 ? v : null;
|
||||||
|
}
|
||||||
|
function decodeSatsIn(raw) { return utils.isNumber(raw) ? (raw > 99 ? raw - 100 : raw) : null; }
|
||||||
|
function decodeCorrectionFields(tslu, calcodeFreq) {
|
||||||
|
const correctionId = utils.isNumber(tslu) ? (tslu > 100 ? tslu - 100 : tslu) : null;
|
||||||
|
let waasId = null;
|
||||||
|
if (utils.isNumber(calcodeFreq) && calcodeFreq >= 20001 && calcodeFreq <= 29999) waasId = calcodeFreq - 20000;
|
||||||
|
return { correctionId, waasId };
|
||||||
|
}
|
||||||
|
function computeAppRateApplied(lminApp, grSpeed, swath) {
|
||||||
|
if (!utils.isNumber(lminApp) || !utils.isNumber(grSpeed) || !utils.isNumber(swath)) return null;
|
||||||
|
if (grSpeed === 0 || swath === 0) return null;
|
||||||
|
return lminApp / (grSpeed * swath) * 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify job ownership — throws on mismatch. */
|
||||||
|
async function ownerJob(jobId, ownerId) {
|
||||||
|
const job = await Job.findOne({ _id: jobId, markedDelete: { $ne: true } }).lean();
|
||||||
|
if (!job) AppParamError.throw(Errors.JOB_NOT_FOUND);
|
||||||
|
if (!job.byPuid || job.byPuid.toString() !== ownerId.toString()) AppAuthError.throw();
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Unit conversion helpers ─────────────────────────────────────────────────
|
||||||
|
// All raw AppDetail values are stored in SI/metric units.
|
||||||
|
// When units='us', these factors convert to US customary equivalents.
|
||||||
|
const CONV = {
|
||||||
|
msToMph: v => +(v * 2.23694).toFixed(4), // m/s → mph
|
||||||
|
mToFt: v => +(v * 3.28084).toFixed(3), // m → ft
|
||||||
|
cToF: v => +(v * 9 / 5 + 32).toFixed(2), // °C → °F
|
||||||
|
LminToGmin: v => +(v * 0.264172).toFixed(4), // L/min → gal/min
|
||||||
|
LhaToGac: v => +(v * 0.10694).toFixed(4), // L/ha → gal/ac
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyConv(v, fn) {
|
||||||
|
return (v != null && v !== '') ? fn(Number(v)) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns CSV column definitions for the requested unit system.
|
||||||
|
* Each entry: { key (row-object property), header (CSV column name) }.
|
||||||
|
*/
|
||||||
|
function getCsvColumns(units) {
|
||||||
|
const us = units === ExportUnits.US;
|
||||||
|
return [
|
||||||
|
// Job / session metadata — no unit conversion
|
||||||
|
{ key: 'jobId' }, { key: 'orderNumber' }, { key: 'jobName' },
|
||||||
|
{ key: 'sessionId' }, { key: 'fileName' }, { key: 'pilotName' },
|
||||||
|
// GPS data
|
||||||
|
{ key: 'timestampUtc' }, { key: 'gpsTime' }, { key: 'lat' }, { key: 'lon' },
|
||||||
|
{ key: 'utmX' }, { key: 'utmY' },
|
||||||
|
{ key: 'alt', header: us ? 'alt_ft' : 'alt_m' },
|
||||||
|
{ key: 'groundSpeed', header: us ? 'groundSpeed_mph' : 'groundSpeed_ms' },
|
||||||
|
{ key: 'heading' },
|
||||||
|
{ key: 'crossTrackError', header: us ? 'crossTrackError_ft' : 'crossTrackError_m' },
|
||||||
|
{ key: 'lockedLine' }, { key: 'hdop' }, { key: 'satsInView' },
|
||||||
|
{ key: 'correctionId' }, { key: 'waasId' },
|
||||||
|
{ key: 'sprayStat' },
|
||||||
|
// Application data
|
||||||
|
{ key: 'flowRateApplied', header: us ? 'flowRateApplied_galMin' : 'flowRateApplied_Lmin' },
|
||||||
|
{ key: 'flowRateRequired', header: us ? 'flowRateRequired_galMin' : 'flowRateRequired_Lmin' },
|
||||||
|
{ key: 'appRateRequired', header: us ? 'appRateRequired_galAc' : 'appRateRequired_Lha' },
|
||||||
|
{ key: 'appRateApplied', header: us ? 'appRateApplied_galAc' : 'appRateApplied_Lha' },
|
||||||
|
{ key: 'swathWidth', header: us ? 'swathWidth_ft' : 'swathWidth_m' },
|
||||||
|
{ key: 'boomPressure_psi' }, // PSI already; no conversion
|
||||||
|
{ key: 'sprayOnLag_s' }, { key: 'sprayOffLag_s' }, { key: 'pulsesPerLitre' },
|
||||||
|
// MET
|
||||||
|
{ key: 'windSpeed', header: us ? 'windSpeed_mph' : 'windSpeed_ms' },
|
||||||
|
{ key: 'windDir_deg' },
|
||||||
|
{ key: 'temp', header: us ? 'temp_f' : 'temp_c' },
|
||||||
|
{ key: 'humidity_pct' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsv(val) {
|
||||||
|
if (val == null) return '';
|
||||||
|
const s = String(val);
|
||||||
|
if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordToRow(d, sessionMeta, jobHeader, units) {
|
||||||
|
const us = units === ExportUnits.US;
|
||||||
|
const { correctionId, waasId } = decodeCorrectionFields(d.tslu, d.calcodeFreq);
|
||||||
|
|
||||||
|
const appRateApplied = computeAppRateApplied(d.lminApp, d.grSpeed, d.swath);
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
...jobHeader,
|
||||||
|
sessionId: sessionMeta.appId,
|
||||||
|
fileName: sessionMeta.fileName,
|
||||||
|
pilotName: sessionMeta.operator ?? '',
|
||||||
|
timestampUtc: d.gpsTime ? moment.unix(d.gpsTime).utc().toISOString() : '',
|
||||||
|
gpsTime: d.gpsTime ?? '',
|
||||||
|
lat: d.lat ?? '', lon: d.lon ?? '',
|
||||||
|
utmX: d.utmX ?? '', utmY: d.utmY ?? '',
|
||||||
|
alt: us ? applyConv(d.alt, CONV.mToFt) : (d.alt ?? ''),
|
||||||
|
groundSpeed: us ? applyConv(d.grSpeed, CONV.msToMph) : (d.grSpeed ?? ''),
|
||||||
|
heading: d.head ?? '',
|
||||||
|
crossTrackError: us ? applyConv(d.xTrack, CONV.mToFt) : (d.xTrack ?? ''),
|
||||||
|
lockedLine: d.llnum ?? '', hdop: d.stdHdop ?? '',
|
||||||
|
satsInView: decodeSatsIn(d.satsIn) ?? '',
|
||||||
|
correctionId: correctionId ?? '', waasId: waasId ?? '',
|
||||||
|
sprayStat: d.sprayStat ?? '',
|
||||||
|
flowRateApplied: us ? applyConv(d.lminApp, CONV.LminToGmin) : (d.lminApp ?? ''),
|
||||||
|
flowRateRequired: us ? applyConv(d.lminReq, CONV.LminToGmin) : (d.lminReq ?? ''),
|
||||||
|
appRateRequired: us ? applyConv(d.lhaReq, CONV.LhaToGac) : (d.lhaReq ?? ''),
|
||||||
|
appRateApplied: us ? applyConv(appRateApplied, CONV.LhaToGac) : (appRateApplied ?? ''),
|
||||||
|
swathWidth: us ? applyConv(d.swath, CONV.mToFt) : (d.swath ?? ''),
|
||||||
|
boomPressure_psi: d.psi ?? '',
|
||||||
|
sprayOnLag_s: sessionMeta.meta?.sprOnLag ?? '',
|
||||||
|
sprayOffLag_s: sessionMeta.meta?.sprOffLag ?? '',
|
||||||
|
pulsesPerLitre: sessionMeta.meta?.pulsesPerLit ?? '',
|
||||||
|
windSpeed: us ? applyConv(d.windSpd, CONV.msToMph) : (d.windSpd ?? ''),
|
||||||
|
windDir_deg: d.windDir ?? '',
|
||||||
|
temp: us ? applyConv(d.temp, CONV.cToF) : (d.temp ?? ''),
|
||||||
|
humidity_pct: d.humid ?? ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const cols = getCsvColumns(units);
|
||||||
|
return cols.map(c => escapeCsv(row[c.key])).join(',') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Async generation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function generateExport(exportJobId) {
|
||||||
|
const exportJob = await ExportJob.findById(exportJobId);
|
||||||
|
if (!exportJob) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
exportJob.status = 'processing';
|
||||||
|
await exportJob.save();
|
||||||
|
|
||||||
|
const job = await Job.findById(exportJob.jobId, 'name orderNumber').lean();
|
||||||
|
const jobHeader = { jobId: exportJob.jobId, orderNumber: job?.orderNumber ?? '', jobName: job?.name ?? '' };
|
||||||
|
|
||||||
|
const apps = await App.find({ jobId: exportJob.jobId, markedDelete: { $ne: true } }).lean();
|
||||||
|
const appFiles = await AppFile.find(
|
||||||
|
{ appId: { $in: apps.map(a => a._id) }, markedDelete: { $ne: true } }
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
const filesByAppId = {};
|
||||||
|
for (const f of appFiles) {
|
||||||
|
const key = f.appId.toString();
|
||||||
|
if (!filesByAppId[key]) filesByAppId[key] = [];
|
||||||
|
filesByAppId[key].push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = exportJob.interval;
|
||||||
|
const outPath = path.join(env.TEMP_DIR, `export_${exportJobId}.${exportJob.format}`);
|
||||||
|
const writeStream = fs.createWriteStream(outPath);
|
||||||
|
|
||||||
|
const units = exportJob.units || 'metric';
|
||||||
|
|
||||||
|
if (exportJob.format === 'csv') {
|
||||||
|
// Write header row (unit-aware column names)
|
||||||
|
const cols = getCsvColumns(units);
|
||||||
|
writeStream.write(cols.map(c => c.header || c.key).join(',') + '\n');
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
const files = filesByAppId[app._id.toString()] || [];
|
||||||
|
for (const appFile of files) {
|
||||||
|
const sessionMeta = { appId: app._id, fileName: app.fileName, operator: appFile.meta?.operator, meta: appFile.meta };
|
||||||
|
|
||||||
|
// Stream AppDetail records for this file using a cursor (memory-efficient).
|
||||||
|
// Exclude sprayStat=3 (spray segment START marker — stores anchor position for
|
||||||
|
// the next area calculation; not an application-data record for consumers).
|
||||||
|
const cursor = AppDetail.find(
|
||||||
|
{ fileId: appFile._id, sprayStat: { $ne: 3 } },
|
||||||
|
null,
|
||||||
|
{ sort: { _id: 1 }, lean: true }
|
||||||
|
).cursor();
|
||||||
|
let prevGpsTime = null;
|
||||||
|
for await (const record of cursor) {
|
||||||
|
if (interval) {
|
||||||
|
if (prevGpsTime !== null && (record.gpsTime - prevGpsTime) < interval) continue;
|
||||||
|
prevGpsTime = record.gpsTime;
|
||||||
|
}
|
||||||
|
writeStream.write(recordToRow(record, sessionMeta, jobHeader, units));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (exportJob.format === 'geojson') {
|
||||||
|
// GeoJSON FeatureCollection — one Feature per GPS point.
|
||||||
|
// sprayStat=3 excluded: it is a spray segment START marker, not application data.
|
||||||
|
writeStream.write('{"type":"FeatureCollection","features":[\n');
|
||||||
|
let first = true;
|
||||||
|
for (const app of apps) {
|
||||||
|
const files = filesByAppId[app._id.toString()] || [];
|
||||||
|
for (const appFile of files) {
|
||||||
|
const cursor = AppDetail.find(
|
||||||
|
{ fileId: appFile._id, sprayStat: { $ne: 3 } },
|
||||||
|
null,
|
||||||
|
{ sort: { _id: 1 }, lean: true }
|
||||||
|
).cursor();
|
||||||
|
let prevGpsTime = null;
|
||||||
|
for await (const d of cursor) {
|
||||||
|
if (interval) {
|
||||||
|
if (prevGpsTime !== null && (d.gpsTime - prevGpsTime) < interval) continue;
|
||||||
|
prevGpsTime = d.gpsTime;
|
||||||
|
}
|
||||||
|
if (!utils.isNumber(d.lon) || !utils.isNumber(d.lat)) continue;
|
||||||
|
const feature = {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [d.lon, d.lat, d.alt ?? 0] },
|
||||||
|
properties: {
|
||||||
|
jobId: exportJob.jobId, sessionId: String(app._id), fileName: app.fileName,
|
||||||
|
timestampUtc: d.gpsTime ? moment.unix(d.gpsTime).utc().toISOString() : null,
|
||||||
|
sprayStat: d.sprayStat, groundSpeed: d.grSpeed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
writeStream.write((first ? '' : ',\n') + JSON.stringify(feature));
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeStream.write('\n]}');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
writeStream.end();
|
||||||
|
writeStream.on('finish', resolve);
|
||||||
|
writeStream.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + EXPORT_TTL_HOURS * 3600 * 1000);
|
||||||
|
exportJob.status = 'ready';
|
||||||
|
exportJob.filePath = outPath;
|
||||||
|
exportJob.expiresAt = expiresAt;
|
||||||
|
await exportJob.save();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
exportJob.status = 'error';
|
||||||
|
exportJob.errorMsg = err.message;
|
||||||
|
await exportJob.save();
|
||||||
|
console.error('[export] generation failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Route handlers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/jobs/:jobId/export
|
||||||
|
* Body: { format: 'csv' | 'geojson', interval?: number }
|
||||||
|
*/
|
||||||
|
async function triggerExport(req, res) {
|
||||||
|
const jobId = parseInt(req.params.jobId, 10);
|
||||||
|
if (!isFinite(jobId)) AppParamError.throw('invalid jobId');
|
||||||
|
|
||||||
|
await ownerJob(jobId, req.uid);
|
||||||
|
|
||||||
|
const format = req.body?.format;
|
||||||
|
if (!['csv', 'geojson'].includes(format)) {
|
||||||
|
return res.status(HttpStatus.BAD_REQUEST).json({ error: 'format must be csv or geojson' });
|
||||||
|
}
|
||||||
|
const interval = parseInterval(req.body?.interval);
|
||||||
|
|
||||||
|
const rawUnits = req.body?.units;
|
||||||
|
const units = rawUnits === ExportUnits.US ? ExportUnits.US : ExportUnits.METRIC; // default metric
|
||||||
|
|
||||||
|
const exportJob = await ExportJob.create({
|
||||||
|
owner: ObjectId(req.uid),
|
||||||
|
jobId,
|
||||||
|
format,
|
||||||
|
interval,
|
||||||
|
units,
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kick off async generation — do not await
|
||||||
|
setImmediate(() => generateExport(exportJob._id));
|
||||||
|
|
||||||
|
res.status(HttpStatus.CREATED).json({
|
||||||
|
exportId: exportJob._id,
|
||||||
|
status: exportJob.status,
|
||||||
|
format: exportJob.format,
|
||||||
|
units: exportJob.units,
|
||||||
|
createdAt: exportJob.createdAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/exports/:exportId
|
||||||
|
* Poll for export status. When ready, includes downloadUrl.
|
||||||
|
*/
|
||||||
|
async function getExportStatus(req, res) {
|
||||||
|
const exportId = req.params.exportId;
|
||||||
|
if (!ObjectId.isValid(exportId)) AppParamError.throw('invalid exportId');
|
||||||
|
|
||||||
|
const exportJob = await ExportJob.findOne({
|
||||||
|
_id: ObjectId(exportId),
|
||||||
|
owner: ObjectId(req.uid)
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
if (!exportJob) return res.status(HttpStatus.NOT_FOUND).json({ error: Errors.NOT_FOUND });
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
exportId: exportJob._id,
|
||||||
|
status: exportJob.status,
|
||||||
|
format: exportJob.format,
|
||||||
|
units: exportJob.units,
|
||||||
|
createdAt: exportJob.createdAt,
|
||||||
|
expiresAt: exportJob.expiresAt ?? null,
|
||||||
|
error: exportJob.errorMsg ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (exportJob.status === 'ready') {
|
||||||
|
// Provide a download URL — the frontend calls this to stream the file
|
||||||
|
payload.downloadUrl = `/api/v1/exports/${exportId}/download`;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/exports/:exportId/download
|
||||||
|
* Streams the generated export file. Schedules file deletion after streaming.
|
||||||
|
*/
|
||||||
|
async function downloadExport(req, res) {
|
||||||
|
const exportId = req.params.exportId;
|
||||||
|
if (!ObjectId.isValid(exportId)) AppParamError.throw('invalid exportId');
|
||||||
|
|
||||||
|
const exportJob = await ExportJob.findOne({
|
||||||
|
_id: ObjectId(exportId),
|
||||||
|
owner: ObjectId(req.uid),
|
||||||
|
status: 'ready'
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
if (!exportJob || !exportJob.filePath) {
|
||||||
|
return res.status(HttpStatus.NOT_FOUND).json({ error: Errors.NOT_FOUND });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = exportJob.format === 'geojson' ? 'geojson' : 'csv';
|
||||||
|
const contentType = exportJob.format === 'geojson' ? 'application/geo+json' : 'text/csv';
|
||||||
|
const filename = `export_job${exportJob.jobId}_${exportJob._id}.${ext}`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
|
||||||
|
const readStream = fs.createReadStream(exportJob.filePath);
|
||||||
|
readStream.pipe(res);
|
||||||
|
|
||||||
|
readStream.on('end', () => {
|
||||||
|
// Clean up file after streaming (fire-and-forget)
|
||||||
|
fs.unlink(exportJob.filePath, () => {});
|
||||||
|
ExportJob.updateOne({ _id: exportJob._id }, { $set: { status: 'pending', filePath: null } }).catch(() => {});
|
||||||
|
});
|
||||||
|
readStream.on('error', (err) => {
|
||||||
|
console.error('[export] stream error', err);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { triggerExport, getExportStatus, downloadExport };
|
||||||
183
Development/server/controllers/api_key.js
Normal file
183
Development/server/controllers/api_key.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const ApiKey = require('../model/api_key');
|
||||||
|
const { AppAuthError, AppParamError, AppInputError } = require('../helpers/app_error');
|
||||||
|
const { Errors, UserTypes, HttpStatus, ApiKeyServices } = require('../helpers/constants');
|
||||||
|
const ObjectId = require('mongodb').ObjectId;
|
||||||
|
|
||||||
|
const KEY_LENGTH_BYTES = 32; // 256-bit random key → 64-char hex string
|
||||||
|
const BCRYPT_ROUNDS = 10;
|
||||||
|
const MAX_KEYS_PER_OWNER = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/keys
|
||||||
|
* Body: { label: string }
|
||||||
|
* Creates a new API key for the authenticated applicator.
|
||||||
|
* Returns the plain key ONCE — it is never retrievable again.
|
||||||
|
* Admin users may supply an optional `ownerId` to create a key on behalf of another account.
|
||||||
|
*/
|
||||||
|
async function createKey(req, res) {
|
||||||
|
const input = req.body;
|
||||||
|
if (!input || !input.label || !String(input.label).trim()) {
|
||||||
|
return AppParamError.throw('label is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = req.ut === UserTypes.ADMIN;
|
||||||
|
let ownerId;
|
||||||
|
if (isAdmin && input.ownerId) {
|
||||||
|
if (!ObjectId.isValid(input.ownerId)) AppParamError.throw('invalid ownerId');
|
||||||
|
ownerId = ObjectId(input.ownerId);
|
||||||
|
} else {
|
||||||
|
ownerId = ObjectId(req.uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce per-owner key limit
|
||||||
|
const existing = await ApiKey.countDocuments({ owner: ownerId, active: true });
|
||||||
|
if (existing >= MAX_KEYS_PER_OWNER) {
|
||||||
|
return AppInputError.throw(`Maximum of ${MAX_KEYS_PER_OWNER} active keys allowed per account`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = input.service || ApiKeyServices.DATA_EXPORT;
|
||||||
|
if (!Object.values(ApiKeyServices).includes(service)) {
|
||||||
|
return AppParamError.throw(`service must be one of: ${Object.values(ApiKeyServices).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainKey = crypto.randomBytes(KEY_LENGTH_BYTES).toString('hex');
|
||||||
|
const prefix = plainKey.substring(0, 8);
|
||||||
|
const keyHash = await bcrypt.hash(plainKey, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
|
const apiKey = await ApiKey.create({
|
||||||
|
owner: ownerId,
|
||||||
|
label: String(input.label).trim(),
|
||||||
|
prefix,
|
||||||
|
keyHash,
|
||||||
|
service,
|
||||||
|
managedBy: isAdmin && input.ownerId ? 'admin' : 'owner'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate owner so the client can display name/username/contact immediately
|
||||||
|
await apiKey.populate('owner', 'username name contact');
|
||||||
|
|
||||||
|
// Return plain key once — include it only in the creation response
|
||||||
|
res.status(HttpStatus.CREATED).json({
|
||||||
|
_id: apiKey._id,
|
||||||
|
label: apiKey.label,
|
||||||
|
prefix: apiKey.prefix,
|
||||||
|
service: apiKey.service,
|
||||||
|
active: apiKey.active,
|
||||||
|
managedBy: apiKey.managedBy,
|
||||||
|
createdAt: apiKey.createdAt,
|
||||||
|
owner: apiKey.owner,
|
||||||
|
// Plain key — shown once, not stored
|
||||||
|
key: plainKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/keys
|
||||||
|
* Returns all active and inactive keys belonging to the authenticated user.
|
||||||
|
* Admin users may supply ?ownerId= to list another account's keys.
|
||||||
|
*/
|
||||||
|
async function listKeys(req, res) {
|
||||||
|
const isAdmin = req?.ut === UserTypes.ADMIN;
|
||||||
|
let filter;
|
||||||
|
if (isAdmin && req.query.ownerId) {
|
||||||
|
if (!ObjectId.isValid(req.query.ownerId)) AppParamError.throw('invalid ownerId');
|
||||||
|
filter = { owner: ObjectId(req.query.ownerId) };
|
||||||
|
} else if (isAdmin) {
|
||||||
|
filter = {}; // Admin without ownerId → return all keys
|
||||||
|
} else {
|
||||||
|
filter = { owner: ObjectId(req.uid) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = ApiKey.find(filter, '-keyHash -__v').sort({ createdAt: -1 });
|
||||||
|
if (isAdmin) query.populate('owner', 'username name contact');
|
||||||
|
const keys = await query.lean();
|
||||||
|
res.json(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/keys/:keyId/revoke
|
||||||
|
* Revokes (soft-deletes by setting active=false) the specified key.
|
||||||
|
* Only system admin can revoke keys.
|
||||||
|
*/
|
||||||
|
async function revokeKey(req, res) {
|
||||||
|
const keyId = req.params.keyId;
|
||||||
|
if (!ObjectId.isValid(keyId)) AppParamError.throw('invalid keyId');
|
||||||
|
|
||||||
|
const isAdmin = req.ut === UserTypes.ADMIN;
|
||||||
|
if (!isAdmin) {
|
||||||
|
return AppAuthError.throw('Only system admin can revoke API keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ApiKey.updateOne({ _id: ObjectId(keyId) }, { $set: { active: false } });
|
||||||
|
if (!result.matchedCount) {
|
||||||
|
return res.status(HttpStatus.NOT_FOUND).json({ error: Errors.NOT_FOUND });
|
||||||
|
}
|
||||||
|
res.status(HttpStatus.NO_CONTENT).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/keys/:keyId
|
||||||
|
* Permanently deletes the specified key.
|
||||||
|
* Owner can delete their own keys; admin can delete any.
|
||||||
|
*/
|
||||||
|
async function deleteKey(req, res) {
|
||||||
|
const keyId = req.params.keyId;
|
||||||
|
if (!ObjectId.isValid(keyId)) AppParamError.throw('invalid keyId');
|
||||||
|
|
||||||
|
const isAdmin = req.ut === UserTypes.ADMIN;
|
||||||
|
const filter = { _id: ObjectId(keyId) };
|
||||||
|
if (!isAdmin) {
|
||||||
|
filter.owner = ObjectId(req.uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ApiKey.deleteOne(filter);
|
||||||
|
if (!result.deletedCount) {
|
||||||
|
return res.status(HttpStatus.NOT_FOUND).json({ error: Errors.NOT_FOUND });
|
||||||
|
}
|
||||||
|
res.status(HttpStatus.NO_CONTENT).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/keys/:keyId/regenerate
|
||||||
|
* Generates a new secret for an existing key, replacing the old hash and prefix.
|
||||||
|
* Owner can regenerate their own keys; admin can regenerate any.
|
||||||
|
* Returns the new plain key ONCE — it is never retrievable again.
|
||||||
|
*/
|
||||||
|
async function regenerateKey(req, res) {
|
||||||
|
const keyId = req.params.keyId;
|
||||||
|
if (!ObjectId.isValid(keyId)) AppParamError.throw('invalid keyId');
|
||||||
|
|
||||||
|
const isAdmin = req.ut === UserTypes.ADMIN;
|
||||||
|
const filter = { _id: ObjectId(keyId) };
|
||||||
|
if (!isAdmin) {
|
||||||
|
filter.owner = ObjectId(req.uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ApiKey.findOne(filter, '_id label prefix service active managedBy createdAt').lean();
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(HttpStatus.NOT_FOUND).json({ error: Errors.NOT_FOUND });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainKey = crypto.randomBytes(KEY_LENGTH_BYTES).toString('hex');
|
||||||
|
const prefix = plainKey.substring(0, 8);
|
||||||
|
const keyHash = await bcrypt.hash(plainKey, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
|
await ApiKey.updateOne({ _id: existing._id }, { $set: { prefix, keyHash, active: true } });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
_id: existing._id,
|
||||||
|
label: existing.label,
|
||||||
|
prefix,
|
||||||
|
service: existing.service,
|
||||||
|
active: true,
|
||||||
|
managedBy: existing.managedBy,
|
||||||
|
createdAt: existing.createdAt,
|
||||||
|
key: plainKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createKey, listKeys, revokeKey, deleteKey, regenerateKey };
|
||||||
366
Development/server/controllers/api_pub.js
Normal file
366
Development/server/controllers/api_pub.js
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Data Export API controller — /api/v1/ routes.
|
||||||
|
* All functions are authenticated via checkApiKey (X-API-Key header).
|
||||||
|
* req.uid is set identically to checkUser, so all ownership scoping is automatic.
|
||||||
|
*
|
||||||
|
* Endpoints implemented here:
|
||||||
|
* GET /api/v1/jobs/:jobId/sessions → session summary list
|
||||||
|
* GET /api/v1/jobs/:jobId/sessions/:fileId/records → raw GPS trace (paginated)
|
||||||
|
* GET /api/v1/jobs/:jobId/areas → GeoJSON spray-area polygons
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ObjectId = require('mongodb').ObjectId;
|
||||||
|
const moment = require('moment');
|
||||||
|
const { Job, App, AppFile, AppDetail, JobAssign, Vehicle, Pilot } = require('../model');
|
||||||
|
const { paginateWithCursor, validateCursorParams } = require('../helpers/cursor_pagination');
|
||||||
|
const { AppParamError, AppAuthError } = require('../helpers/app_error');
|
||||||
|
const { Errors, HttpStatus } = require('../helpers/constants');
|
||||||
|
const utils = require('../helpers/utils');
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Parse a positive-float interval value from a query/body param. Returns null if absent or invalid. */
|
||||||
|
function parseInterval(raw) {
|
||||||
|
if (raw == null || raw === '') return null;
|
||||||
|
const v = parseFloat(raw);
|
||||||
|
return isFinite(v) && v > 0 ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode satsInView from raw satsIn field (>99 means corrected) */
|
||||||
|
function decodeSatsIn(raw) {
|
||||||
|
if (!utils.isNumber(raw)) return null;
|
||||||
|
return raw > 99 ? raw - 100 : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode correctionId and waasId from tslu and calcodeFreq */
|
||||||
|
function decodeCorrectionFields(tslu, calcodeFreq) {
|
||||||
|
const correctionId = utils.isNumber(tslu) ? (tslu > 100 ? tslu - 100 : tslu) : null;
|
||||||
|
let waasId = null;
|
||||||
|
if (utils.isNumber(calcodeFreq) && calcodeFreq >= 20001 && calcodeFreq <= 29999) {
|
||||||
|
waasId = calcodeFreq - 20000;
|
||||||
|
}
|
||||||
|
return { correctionId, waasId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute appRateApplied from raw fields.
|
||||||
|
* Formula: lminApp / (grSpeed_m_s × swath_m) × 10000
|
||||||
|
* Returns null on zero-division to avoid Infinity.
|
||||||
|
*/
|
||||||
|
function computeAppRateApplied(lminApp, grSpeed, swath) {
|
||||||
|
if (!utils.isNumber(lminApp) || !utils.isNumber(grSpeed) || !utils.isNumber(swath)) return null;
|
||||||
|
if (grSpeed === 0 || swath === 0) return null;
|
||||||
|
return lminApp / (grSpeed * swath) * 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a raw AppDetail document to the public API record shape.
|
||||||
|
* sessionMeta contains session-constant fields from AppFile.meta injected once per page.
|
||||||
|
*/
|
||||||
|
function mapDetailRecord(d, sessionMeta) {
|
||||||
|
const { correctionId, waasId } = decodeCorrectionFields(d.tslu, d.calcodeFreq);
|
||||||
|
return {
|
||||||
|
// GPS Data
|
||||||
|
timestampUtc: d.gpsTime ? moment.unix(d.gpsTime).utc().toISOString() : null,
|
||||||
|
gpsTime: d.gpsTime,
|
||||||
|
lat: d.lat,
|
||||||
|
lon: d.lon,
|
||||||
|
utmX: d.utmX,
|
||||||
|
utmY: d.utmY,
|
||||||
|
alt: d.alt,
|
||||||
|
groundSpeed: d.grSpeed,
|
||||||
|
heading: d.head,
|
||||||
|
crossTrackError: d.xTrack,
|
||||||
|
lockedLine: d.llnum,
|
||||||
|
hdop: d.stdHdop,
|
||||||
|
satsInView: decodeSatsIn(d.satsIn),
|
||||||
|
correctionId,
|
||||||
|
waasId,
|
||||||
|
sprayStat: d.sprayStat,
|
||||||
|
// Application Info
|
||||||
|
flowRateApplied: d.lminApp,
|
||||||
|
flowRateRequired: d.lminReq,
|
||||||
|
appRateRequired: d.lhaReq,
|
||||||
|
appRateApplied: computeAppRateApplied(d.lminApp, d.grSpeed, d.swath),
|
||||||
|
swathWidth: d.swath,
|
||||||
|
boomPressure_psi: d.psi,
|
||||||
|
// Session-constant fields from AppFile.meta (repeated per record for flat-file consumers)
|
||||||
|
sprayOnLag_s: sessionMeta?.sprOnLag ?? null,
|
||||||
|
sprayOffLag_s: sessionMeta?.sprOffLag ?? null,
|
||||||
|
pulsesPerLitre: sessionMeta?.pulsesPerLit ?? null,
|
||||||
|
rpm: d.rpm,
|
||||||
|
// MET
|
||||||
|
windSpeed_ms: d.windSpd,
|
||||||
|
windDir_deg: d.windDir,
|
||||||
|
temp_c: d.temp,
|
||||||
|
humidity_pct: d.humid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the confirmed-values block for a session, with fallback to raw aggregates.
|
||||||
|
* @param {Object} job - lean Job document (needs rptOp, useCustWI, weatherInfo, sprayAreas)
|
||||||
|
* @param {Object[]} apps - lean App[] for this job
|
||||||
|
*/
|
||||||
|
function buildConfirmedValues(job, apps) {
|
||||||
|
const rptOp = job.rptOp;
|
||||||
|
const reportConfirmed = !!(rptOp && rptOp.coverage != null);
|
||||||
|
|
||||||
|
// Area size: confirmed or sum of sprayArea polygons
|
||||||
|
const areaSize_ha = reportConfirmed
|
||||||
|
? rptOp.areaSize
|
||||||
|
: (Array.isArray(job.sprayAreas) && job.sprayAreas.length
|
||||||
|
? job.sprayAreas.reduce((s, a) => s + (a.properties?.area || 0), 0)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
// Coverage: confirmed or sum of App.totalSprayed
|
||||||
|
const coverage_ha = reportConfirmed
|
||||||
|
? rptOp.coverage
|
||||||
|
: apps.reduce((s, a) => s + (a.totalSprayed || 0), 0);
|
||||||
|
|
||||||
|
// AppRate: confirmed or null (cannot reliably aggregate across files with different units)
|
||||||
|
const appRate = reportConfirmed ? rptOp.appRate : null;
|
||||||
|
|
||||||
|
const sprayVolume = (utils.isNumber(coverage_ha) && utils.isNumber(appRate))
|
||||||
|
? coverage_ha * appRate
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const useActualVolume = reportConfirmed ? !!(rptOp.useActualVol) : false;
|
||||||
|
const actualVolume = (reportConfirmed && useActualVolume) ? (rptOp.actualVol ?? null) : null;
|
||||||
|
const effectiveVolume = useActualVolume ? actualVolume : sprayVolume;
|
||||||
|
|
||||||
|
const result = { reportConfirmed, areaSize_ha, coverage_ha, appRate, sprayVolume, useActualVolume, actualVolume, effectiveVolume };
|
||||||
|
|
||||||
|
// Custom weather — only include when manually entered
|
||||||
|
if (job.useCustWI && job.weatherInfo) {
|
||||||
|
result.customWeather = {
|
||||||
|
windSpeed_kt: job.weatherInfo.windSpd ?? null,
|
||||||
|
windDir: job.weatherInfo.windDir ?? null,
|
||||||
|
temp_c: job.weatherInfo.temp ?? null,
|
||||||
|
humidity_pct: job.weatherInfo.humid ?? null
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
result.customWeather = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify the job belongs to the authenticated owner (req.uid via byPuid). */
|
||||||
|
async function ownerJob(jobId, ownerId) {
|
||||||
|
const job = await Job.findOne({ _id: jobId, markedDelete: { $ne: true } })
|
||||||
|
.populate('operator', '_id name')
|
||||||
|
.populate('vehicle', '_id name tailNumber')
|
||||||
|
.lean();
|
||||||
|
if (!job) AppParamError.throw(Errors.JOB_NOT_FOUND);
|
||||||
|
if (!job.byPuid || job.byPuid.toString() !== ownerId.toString()) AppAuthError.throw();
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Session Summary ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/jobs/:jobId/sessions
|
||||||
|
*
|
||||||
|
* Returns one summary record per uploaded application session (App + AppFile).
|
||||||
|
* Includes reportConfirmed block with fallback to raw aggregates.
|
||||||
|
*
|
||||||
|
* FE / integration note:
|
||||||
|
* - Poll this endpoint after the file-upload job status becomes "done".
|
||||||
|
* - Re-fetch when reportConfirmed changes from false to true (applicator confirms report).
|
||||||
|
*/
|
||||||
|
async function getSessions(req, res) {
|
||||||
|
const jobId = parseInt(req.params.jobId, 10);
|
||||||
|
if (!isFinite(jobId)) AppParamError.throw('invalid jobId');
|
||||||
|
|
||||||
|
const job = await ownerJob(jobId, req.uid);
|
||||||
|
|
||||||
|
// Get all non-deleted Apps for this job
|
||||||
|
const apps = await App.find({ jobId, markedDelete: { $ne: true } })
|
||||||
|
.sort({ createdDate: 1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (!apps.length) {
|
||||||
|
return res.json({ data: [], jobId, reportConfirmed: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appIds = apps.map(a => a._id);
|
||||||
|
|
||||||
|
// Get all AppFiles grouped by appId
|
||||||
|
const appFiles = await AppFile.find({ appId: { $in: appIds }, markedDelete: { $ne: true } })
|
||||||
|
.sort({ agn: 1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const filesByApp = {};
|
||||||
|
for (const f of appFiles) {
|
||||||
|
const key = f.appId.toString();
|
||||||
|
if (!filesByApp[key]) filesByApp[key] = [];
|
||||||
|
filesByApp[key].push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest JobAssign for pilot traceability
|
||||||
|
const assign = await JobAssign.findOne({ jobId, status: { $gte: 0 } })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const confirmedBlock = buildConfirmedValues(job, apps);
|
||||||
|
|
||||||
|
const sessions = apps.map(app => {
|
||||||
|
const files = filesByApp[app._id.toString()] || [];
|
||||||
|
const firstFile = files[0]; // primary file for metadata
|
||||||
|
const meta = firstFile?.meta || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: app._id,
|
||||||
|
fileName: app.fileName,
|
||||||
|
status: app.status,
|
||||||
|
proStatus: app.proStatus,
|
||||||
|
startDateTime: app.startDateTime,
|
||||||
|
endDateTime: app.endDateTime,
|
||||||
|
// Timing
|
||||||
|
totalFlightTime_s: app.totalFlightTime ?? null,
|
||||||
|
totalSprayTime_s: app.totalSprayTime ?? null,
|
||||||
|
totalTurnTime_s: app.totalTurnTime ?? null,
|
||||||
|
// Application
|
||||||
|
totalSprayed_ha: app.totalSprayed ?? null,
|
||||||
|
totalSprayMat: app.totalSprayMat ?? null,
|
||||||
|
totalSprayMatUnit: app.totalSprayMatUnit ?? null,
|
||||||
|
avgSpraySpeed_ms: app.avgSpraySpeed ?? null,
|
||||||
|
// File metadata (from first AppFile)
|
||||||
|
sprayZoneName: meta.areaOrZone ?? null,
|
||||||
|
sprayZoneArea_ha: meta.sprCoverage?.[1] ?? null,
|
||||||
|
appRate: meta.appRate ?? null,
|
||||||
|
appRateUnit: meta.appRateUnitStr ?? null,
|
||||||
|
matType: meta.matType ?? null, // 'wet' | 'dry'
|
||||||
|
flowController: meta.fcName ?? null,
|
||||||
|
sprayOnLag_s: meta.sprOnLag ?? null,
|
||||||
|
sprayOffLag_s: meta.sprOffLag ?? null,
|
||||||
|
pulsesPerLitre: meta.pulsesPerLit ?? null,
|
||||||
|
// Per-session files list (for consumers that need fileId to fetch records)
|
||||||
|
files: files.map(f => ({ fileId: f._id, name: f.name, agn: f.agn })),
|
||||||
|
// Pilot traceability
|
||||||
|
sessionPilotName: meta.operator ?? null, // name as recorded in the data file
|
||||||
|
pilotId: job.operator?._id ?? null,
|
||||||
|
pilotName: job.operator?.name ?? null,
|
||||||
|
aircraftName: job.vehicle?.name ?? null,
|
||||||
|
aircraftTailNumber: job.vehicle?.tailNumber ?? null,
|
||||||
|
assignedDate: assign?.createdAt ?? null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
jobId,
|
||||||
|
...confirmedBlock,
|
||||||
|
data: sessions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Raw GPS Trace Records ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/jobs/:jobId/sessions/:fileId/records
|
||||||
|
* Query: startingAfter, endingBefore, limit (default 500, max 2000), interval (seconds float)
|
||||||
|
*
|
||||||
|
* Returns cursor-paginated AppDetail records for one AppFile.
|
||||||
|
* sprayStat=3 (internal segment marker) is excluded.
|
||||||
|
* interval=N returns one record per N-second GPS time window (thinning for large exports).
|
||||||
|
*
|
||||||
|
* FE / integration note:
|
||||||
|
* - Use startingAfter cursor from previous page's last_id to paginate forward.
|
||||||
|
* - For Power BI incremental refresh: use interval=1 or interval=5 for overview.
|
||||||
|
* - For ArcGIS import: use the /export endpoint instead (full async download).
|
||||||
|
*/
|
||||||
|
async function getSessionRecords(req, res) {
|
||||||
|
const jobId = parseInt(req.params.jobId, 10);
|
||||||
|
const fileId = req.params.fileId;
|
||||||
|
|
||||||
|
if (!isFinite(jobId)) AppParamError.throw('invalid jobId');
|
||||||
|
if (!ObjectId.isValid(fileId)) AppParamError.throw('invalid fileId');
|
||||||
|
|
||||||
|
// Verify job ownership (also confirms job exists)
|
||||||
|
await ownerJob(jobId, req.uid);
|
||||||
|
|
||||||
|
// Verify the AppFile belongs to this job
|
||||||
|
const appFile = await AppFile.findOne({ _id: ObjectId(fileId), markedDelete: { $ne: true } }).lean();
|
||||||
|
if (!appFile) AppParamError.throw(Errors.NOT_FOUND);
|
||||||
|
|
||||||
|
// Verify the App (session) belongs to this job
|
||||||
|
const app = await App.findOne({ _id: appFile.appId, jobId }).lean();
|
||||||
|
if (!app) AppAuthError.throw();
|
||||||
|
|
||||||
|
const params = { ...req.query };
|
||||||
|
// Apply 2000-record hard cap for raw trace endpoint
|
||||||
|
if (!params.limit) params.limit = 500;
|
||||||
|
const requestedLimit = parseInt(params.limit);
|
||||||
|
if (requestedLimit > 2000) params.limit = 2000;
|
||||||
|
|
||||||
|
const validation = validateCursorParams(params);
|
||||||
|
if (!validation.valid) return res.status(HttpStatus.BAD_REQUEST).json({ error: validation.error });
|
||||||
|
|
||||||
|
const interval = parseInterval(params.interval);
|
||||||
|
const sessionMeta = appFile.meta || {};
|
||||||
|
|
||||||
|
// Base filter: exclude internal segment markers (sprayStat=3)
|
||||||
|
const baseFilter = { fileId: ObjectId(fileId), sprayStat: { $ne: 3 } };
|
||||||
|
|
||||||
|
const result = await paginateWithCursor(AppDetail, params, baseFilter, { cursorField: '_id' });
|
||||||
|
|
||||||
|
// Apply interval thinning if requested
|
||||||
|
let records = result.data || [];
|
||||||
|
if (interval && records.length) {
|
||||||
|
const thinned = [];
|
||||||
|
let windowStart = null;
|
||||||
|
for (const r of records) {
|
||||||
|
if (windowStart === null || (r.gpsTime - windowStart) >= interval) {
|
||||||
|
thinned.push(r);
|
||||||
|
windowStart = r.gpsTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
records = thinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...result,
|
||||||
|
data: records.map(d => mapDetailRecord(d, sessionMeta))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Spray-Area GeoJSON Polygons ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/jobs/:jobId/areas
|
||||||
|
*
|
||||||
|
* Returns the planned spray-area polygons as a GeoJSON FeatureCollection.
|
||||||
|
* Each Feature includes area metadata (name, planned appRate, area_ha) in properties.
|
||||||
|
*
|
||||||
|
* FE / integration note:
|
||||||
|
* - Import directly as an ArcGIS layer once the endpoint is confirmed.
|
||||||
|
* - This endpoint is gated on AMAGGI confirming the GeoJSON boundary requirement.
|
||||||
|
*/
|
||||||
|
async function getAreas(req, res) {
|
||||||
|
const jobId = parseInt(req.params.jobId, 10);
|
||||||
|
if (!isFinite(jobId)) AppParamError.throw('invalid jobId');
|
||||||
|
|
||||||
|
const job = await ownerJob(jobId, req.uid);
|
||||||
|
|
||||||
|
const features = (job.sprayAreas || []).map(area => ({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
name: area.properties?.name ?? null,
|
||||||
|
appRate: area.properties?.appRate ?? null,
|
||||||
|
area_ha: area.properties?.area ?? null,
|
||||||
|
type: area.properties?.type ?? null
|
||||||
|
},
|
||||||
|
geometry: area.geometry
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
jobId,
|
||||||
|
features
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getSessions, getSessionRecords, getAreas };
|
||||||
@ -120,7 +120,8 @@ async function assertQueues(channel, queueName, dlqName, withDLX = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await channel.assertQueue(queueName, { durable: true });
|
// Just verify main queue exists; don't try to reassert it with different args
|
||||||
|
await channel.checkQueue(queueName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -766,9 +767,9 @@ exports.retryAllDLQ_post = async (req, res, next) => {
|
|||||||
connection = await createRabbitMQConnection();
|
connection = await createRabbitMQConnection();
|
||||||
channel = await connection.createChannel();
|
channel = await connection.createChannel();
|
||||||
|
|
||||||
// Check if queues exist
|
// Check main queue exists without modifying its args; assert DLQ (no special args)
|
||||||
await channel.checkQueue(queueName);
|
await channel.checkQueue(queueName);
|
||||||
await channel.checkQueue(dlqName);
|
await channel.assertQueue(dlqName, { durable: true });
|
||||||
|
|
||||||
let retriedCount = 0;
|
let retriedCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
@ -858,7 +859,9 @@ exports.retryDLQByPosition_post = async (req, res, next) => {
|
|||||||
connection = await createRabbitMQConnection();
|
connection = await createRabbitMQConnection();
|
||||||
channel = await connection.createChannel();
|
channel = await connection.createChannel();
|
||||||
|
|
||||||
|
// Check main queue exists without modifying its args; assert DLQ (no special args)
|
||||||
await channel.checkQueue(queueName);
|
await channel.checkQueue(queueName);
|
||||||
|
await channel.assertQueue(dlqName, { durable: true });
|
||||||
const dlqInfo = await channel.checkQueue(dlqName);
|
const dlqInfo = await channel.checkQueue(dlqName);
|
||||||
|
|
||||||
if (position >= dlqInfo.messageCount) {
|
if (position >= dlqInfo.messageCount) {
|
||||||
@ -992,8 +995,9 @@ exports.retryDLQByHeader_post = async (req, res, next) => {
|
|||||||
connection = await createRabbitMQConnection();
|
connection = await createRabbitMQConnection();
|
||||||
channel = await connection.createChannel();
|
channel = await connection.createChannel();
|
||||||
|
|
||||||
|
// Check main queue exists without modifying its args; assert DLQ (no special args)
|
||||||
await channel.checkQueue(queueName);
|
await channel.checkQueue(queueName);
|
||||||
await channel.checkQueue(dlqName);
|
await channel.assertQueue(dlqName, { durable: true });
|
||||||
|
|
||||||
let retriedCount = 0;
|
let retriedCount = 0;
|
||||||
let scannedCount = 0;
|
let scannedCount = 0;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ module.exports = function (locals) {
|
|||||||
const
|
const
|
||||||
async = require('async'),
|
async = require('async'),
|
||||||
assert = require('assert'),
|
assert = require('assert'),
|
||||||
|
crypto = require('crypto'),
|
||||||
debug = require('debug')('agm:job'),
|
debug = require('debug')('agm:job'),
|
||||||
ObjectId = require('mongodb').ObjectId,
|
ObjectId = require('mongodb').ObjectId,
|
||||||
{ Job, JobLog, App, AppFile, AppDetail, Customer, JobAssign, Vehicle, Pilot, RptVar, User } = require('../model'),
|
{ Job, JobLog, App, AppFile, AppDetail, Customer, JobAssign, Vehicle, Pilot, RptVar, User } = require('../model'),
|
||||||
@ -29,13 +30,56 @@ module.exports = function (locals) {
|
|||||||
{ AppParamError, AppError, AppAuthError, AppInputError } = require('../helpers/app_error'),
|
{ AppParamError, AppError, AppAuthError, AppInputError } = require('../helpers/app_error'),
|
||||||
{ getFormattedAddress, getDocumentCountry } = require('../helpers/user_helper'),
|
{ getFormattedAddress, getDocumentCountry } = require('../helpers/user_helper'),
|
||||||
env = require('../helpers/env'),
|
env = require('../helpers/env'),
|
||||||
|
redisCache = require('../helpers/redis_cache'),
|
||||||
partnerSyncService = require('../services/partner_sync_service'),
|
partnerSyncService = require('../services/partner_sync_service'),
|
||||||
taskQHelper = require('../helpers/job_queue').getInstance(),
|
taskQHelper = require('../helpers/job_queue').getInstance(),
|
||||||
{ paginateWithCursor, validateCursorParams } = require('../helpers/cursor_pagination'),
|
{ paginateWithCursor, validateCursorParams } = require('../helpers/cursor_pagination'),
|
||||||
|
{ buildDynamicFilter } = require('../helpers/dynamic_filter'),
|
||||||
Joi = require('joi');
|
Joi = require('joi');
|
||||||
|
|
||||||
Joi.objectId = require('joi-objectid')(Joi);
|
Joi.objectId = require('joi-objectid')(Joi);
|
||||||
|
|
||||||
|
const JOB_FILTER_SCHEMA = {
|
||||||
|
client: 'objectid',
|
||||||
|
_id: 'objectid-text',
|
||||||
|
orderNumber: 'text',
|
||||||
|
name: 'text',
|
||||||
|
startDate: 'date',
|
||||||
|
endDate: 'date',
|
||||||
|
createdAt: 'date-preset',
|
||||||
|
status: 'numeric-enum',
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOBS_CACHE_TTL = env.JOBS_CACHE_TTL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a deterministic cache key for the jobs list endpoint.
|
||||||
|
* Normalises query params by sorting keys so that identical filter sets always
|
||||||
|
* produce the same key regardless of the order params were appended.
|
||||||
|
* @param {string} userScope - 'admin' or the user's puid string
|
||||||
|
* @param {object} query - req.query
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildJobsListCacheKey(userScope, query) {
|
||||||
|
const normalized = JSON.stringify(
|
||||||
|
Object.fromEntries(Object.keys(query).sort().map(k => [k, query[k]]))
|
||||||
|
);
|
||||||
|
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
||||||
|
return `jobs:list:${userScope}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all cached jobs-list entries for a given puid scope and for the
|
||||||
|
* admin scope (since admin queries span all accounts).
|
||||||
|
* Fire-and-forget — errors are silently swallowed so they never block a response.
|
||||||
|
* @param {string|ObjectId} puid
|
||||||
|
*/
|
||||||
|
function invalidateJobsListCache(puid) {
|
||||||
|
if (!JOBS_CACHE_TTL) return;
|
||||||
|
redisCache.delByPattern(`jobs:list:${puid}:*`).catch(() => {});
|
||||||
|
redisCache.delByPattern('jobs:list:admin:*').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the GET request to retrieve a list of jobs based on the provided filters.
|
* Handles the GET request to retrieve a list of jobs based on the provided filters.
|
||||||
@ -62,28 +106,50 @@ module.exports = function (locals) {
|
|||||||
const userInfo = req.userInfo;
|
const userInfo = req.userInfo;
|
||||||
if (!userInfo) AppAuthError.throw();
|
if (!userInfo) AppAuthError.throw();
|
||||||
|
|
||||||
const clientId = req.query['clientId'];
|
// Determine scope for cache key once so it can be reused for both read and write.
|
||||||
let filter = {
|
const userScope = req.ut === UserTypes.ADMIN ? 'admin' : String(userInfo.puid);
|
||||||
markedDelete: { $in: [null, false] },
|
if (JOBS_CACHE_TTL) {
|
||||||
...(utils.isObjectId(clientId) ? { client: ObjectId(clientId) } : { byPuid: ObjectId(userInfo.puid) })
|
const cached = await redisCache.get(buildJobsListCacheKey(userScope, req.query));
|
||||||
};
|
if (cached) return res.json(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtersJson = req.query['filters'];
|
||||||
|
let filter = { markedDelete: { $in: [null, false] } };
|
||||||
|
let dynFilter = {};
|
||||||
|
|
||||||
|
if (filtersJson) {
|
||||||
|
// Filter-submit path: all conditions come through the filters param.
|
||||||
|
// Non-admin users are always scoped to their own master account (byPuid)
|
||||||
|
// to prevent cross-account data leakage.
|
||||||
|
if (req.ut !== UserTypes.ADMIN) {
|
||||||
|
filter['byPuid'] = ObjectId(userInfo.puid);
|
||||||
|
}
|
||||||
|
dynFilter = buildDynamicFilter(filtersJson, JOB_FILTER_SCHEMA);
|
||||||
|
!env.PRODUCTION && debug('dynFilter: %j', dynFilter);
|
||||||
|
} else {
|
||||||
|
// Legacy reload path: use individual query params.
|
||||||
|
const clientId = req.query['clientId'];
|
||||||
|
Object.assign(filter, utils.isObjectId(clientId) ? { client: ObjectId(clientId) } : { byPuid: ObjectId(userInfo.puid) });
|
||||||
|
if (req.query['byTime']) {
|
||||||
|
Object.assign(filter, mongoUtil.getDateFilter(req.query['byTime'], 'createdAt'));
|
||||||
|
}
|
||||||
|
const status = Number(req.query['status']);
|
||||||
|
if (status && Object.values(JobStatus).includes(status)) {
|
||||||
|
filter['status'] = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jpo (jobs by pilot) applies to both paths
|
||||||
const jobsByPilot = utils.stringToBoolean(req.query['jpo']);
|
const jobsByPilot = utils.stringToBoolean(req.query['jpo']);
|
||||||
if (jobsByPilot) {
|
if (jobsByPilot) {
|
||||||
const pilot = await Pilot.findById(ObjectId(req.uid), '_id', { lean: true });
|
const pilot = await Pilot.findById(ObjectId(req.uid), '_id', { lean: true });
|
||||||
if (!pilot) AppError.throw(Errors.PILOT_NOT_EXIST);
|
if (!pilot) AppError.throw(Errors.PILOT_NOT_EXIST);
|
||||||
|
|
||||||
filter['operator'] = pilot._id;
|
filter['operator'] = pilot._id;
|
||||||
}
|
}
|
||||||
if (req.query['byTime']) {
|
|
||||||
filter = { ...filter, ... (mongoUtil.getDateFilter(req.query['byTime'], 'createdAt')) };
|
|
||||||
}
|
|
||||||
const status = Number(req.query['status']);
|
|
||||||
if (status && Object.values(JobStatus).includes(status)) {
|
|
||||||
filter['status'] = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pipeline = [
|
const pipeline = [
|
||||||
{ $match: filter },
|
{ $match: filter },
|
||||||
|
...(Object.keys(dynFilter).length > 0 ? [{ $match: dynFilter }] : []),
|
||||||
{
|
{
|
||||||
$project: {
|
$project: {
|
||||||
_id: 1, orderNumber: 1, name: 1, createdAt: 1, startDate: 1, endDate: 1, status: 1, client: 1, costings: 1, invoiceStatus: 1, invoiceId: 1
|
_id: 1, orderNumber: 1, name: 1, createdAt: 1, startDate: 1, endDate: 1, status: 1, client: 1, costings: 1, invoiceStatus: 1, invoiceId: 1
|
||||||
@ -138,6 +204,9 @@ module.exports = function (locals) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const jobs = await Job.aggregate(pipeline);
|
const jobs = await Job.aggregate(pipeline);
|
||||||
|
if (JOBS_CACHE_TTL) {
|
||||||
|
redisCache.set(buildJobsListCacheKey(userScope, req.query), jobs, JOBS_CACHE_TTL).catch(() => {});
|
||||||
|
}
|
||||||
res.json(jobs);
|
res.json(jobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,6 +258,7 @@ module.exports = function (locals) {
|
|||||||
insertedJob.operator = _job.operator;
|
insertedJob.operator = _job.operator;
|
||||||
insertedJob.vehicle = _job.vehicle;
|
insertedJob.vehicle = _job.vehicle;
|
||||||
res.json(insertedJob);
|
res.json(insertedJob);
|
||||||
|
invalidateJobsListCache(req.userInfo?.puid);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getJob_get(req, res) {
|
async function getJob_get(req, res) {
|
||||||
@ -367,13 +437,15 @@ module.exports = function (locals) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json(retJob);
|
res.json(retJob);
|
||||||
|
invalidateJobsListCache(req.userInfo?.puid);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteJob(req, res) {
|
async function deleteJob(req, res) {
|
||||||
const job = await Job.findById(req.params.job_id);
|
const job = await Job.findById(req.params.job_id);
|
||||||
|
const puid = req.userInfo?.puid || job?.byPuid;
|
||||||
if (job) await job.removeFull();
|
if (job) await job.removeFull();
|
||||||
|
|
||||||
res.json({ ok: true }).end();
|
res.json({ ok: true }).end();
|
||||||
|
invalidateJobsListCache(puid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
580
Development/server/docs/DATA_EXPORT_API_DESIGN.md
Normal file
580
Development/server/docs/DATA_EXPORT_API_DESIGN.md
Normal file
@ -0,0 +1,580 @@
|
|||||||
|
# Data Export API — Design & Implementation Guide
|
||||||
|
|
||||||
|
Single Source of Truth: This is the canonical document for Data Export API design, implementation status, and next steps in this branch.
|
||||||
|
|
||||||
|
**Branch:** `data-export-api`
|
||||||
|
**Date:** April 10, 2026
|
||||||
|
**Status:** Phase A complete — Phase B in progress
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
| Date | Update |
|
||||||
|
|---|---|
|
||||||
|
| 2026-04-14 | Added `ApiKeyServices` and `ExportUnits` frozen constants to `helpers/constants.js`; wired throughout models and controllers. Added `service` field to `ApiKey`, `units` field to `ExportJob`, US unit conversion support to async export. |
|
||||||
|
| 2026-04-10 | Marked this file as the single source of truth for Data Export API design and implementation tracking. |
|
||||||
|
| 2026-04-10 | Consolidated documentation into this file and removed duplicate summary document. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The Data Export API allows authorised external systems (data warehouses, Power BI, ArcGIS) to pull mission data from AgMission on demand or on a scheduled basis. It exposes the same data already shown in the web application's **Data Playback** screen, served through a versioned REST API authenticated with API keys.
|
||||||
|
|
||||||
|
Two functional areas:
|
||||||
|
|
||||||
|
1. **REST API** (`/api/v1/`) — session summaries, per-point GPS trace, spray-area polygons, async bulk export
|
||||||
|
2. **UI enhancement** — improved Job List filter controls (order number, date range) and an API Key management screen in the web app settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
### 2.1 Request Flow & Authentication Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant External as External System
|
||||||
|
participant API as Express Server
|
||||||
|
participant Auth as checkApiKey Middleware
|
||||||
|
participant DB as ApiKey DB
|
||||||
|
participant Handler as Route Handler
|
||||||
|
|
||||||
|
External->>API: GET /api/v1/jobs/:id/sessions
|
||||||
|
External->>API: Header X-API-Key
|
||||||
|
API->>Auth: req.headers x-api-key
|
||||||
|
Auth->>Auth: Extract prefix first 8 chars
|
||||||
|
Auth->>DB: Find by prefix active=true
|
||||||
|
DB-->>Auth: ApiKey candidates
|
||||||
|
Auth->>Auth: bcrypt.compare plainKey vs keyHash
|
||||||
|
Auth->>Auth: On match set req.uid
|
||||||
|
Auth->>Handler: next with req.uid set
|
||||||
|
Handler->>Handler: ownerJob verify req.uid
|
||||||
|
Handler-->>External: JSON response
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web UI caller (JWT-authenticated, unchanged):**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[Web App] -->|Bearer token| B[checkUser Middleware]
|
||||||
|
B -->|Verify JWT| C[req.uid set]
|
||||||
|
C -->|api/keys routes| D[Key Management]
|
||||||
|
D -->|CRUD ops| E[ApiKey Model]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Data Model Hierarchy
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Job[Job model]
|
||||||
|
App[App - Session data]
|
||||||
|
AppFile[AppFile - Metadata]
|
||||||
|
AppDetail[AppDetail - GPS points]
|
||||||
|
ExportJob[ExportJob - Export tracker]
|
||||||
|
ApiKey[ApiKey - Authentication]
|
||||||
|
|
||||||
|
Job -->|has many| App
|
||||||
|
App -->|has many| AppFile
|
||||||
|
AppFile -->|has many| AppDetail
|
||||||
|
Job -.->|triggers| ExportJob
|
||||||
|
Job -.->|auth via| ApiKey
|
||||||
|
App -.->|derived from| AppDetail
|
||||||
|
style Job fill:#e1f5ff
|
||||||
|
style App fill:#f3e5f5
|
||||||
|
style AppFile fill:#fff3e0
|
||||||
|
style AppDetail fill:#fce4ec
|
||||||
|
style ExportJob fill:#e8f5e9
|
||||||
|
style ApiKey fill:#f1f8e9
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields summary:**
|
||||||
|
- **Job**: jobId, byPuid, rptOp, weatherInfo, sprayAreas
|
||||||
|
- **App**: avgSpraySpeed, totalSprayed, totalSprayTime, totalFlightTime
|
||||||
|
- **AppFile**: meta (operator, appRate, fcName, sprOnLag), totalSprayed, totalSprayTime
|
||||||
|
- **AppDetail**: gpsTime, lat, lon, grSpeed, lminApp, swath, sprayStat, windSpd, temp, humid
|
||||||
|
- **ExportJob**: owner, jobId, format, status, filePath, expiresAt
|
||||||
|
- **ApiKey**: owner, keyHash, prefix, active, lastUsedAt
|
||||||
|
|
||||||
|
### 2.3 Route Prefix Strategy
|
||||||
|
|
||||||
|
| Prefix | Auth | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/v1/` | `X-API-Key` header (new `checkApiKey`) | Public data export endpoints |
|
||||||
|
| `/api/keys` | `Authorization: Bearer` (existing `checkUser`) | Key management for web UI |
|
||||||
|
| All other `/api/...` | `Authorization: Bearer` (existing `checkUser`) | Existing application routes — unchanged |
|
||||||
|
|
||||||
|
The `/api/v1/` path is added to the `checkUser` bypass whitelist (in `isSecuredRoute()`) so the existing JWT middleware skips these routes.
|
||||||
|
|
||||||
|
### 2.4 Async Export Lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> pending: POST /export
|
||||||
|
pending --> processing: async generate
|
||||||
|
processing --> ready: success
|
||||||
|
processing --> error: fail I/O error
|
||||||
|
ready --> pending: download cleanup
|
||||||
|
error --> [*]: TTL expiry
|
||||||
|
ready --> [*]: 24h TTL
|
||||||
|
pending --> [*]: TTL index
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lifecycle details:**
|
||||||
|
- **pending**: ExportJob created and returned to caller; caller polls GET /exports/:id
|
||||||
|
- **processing**: Streams AppDetail cursor to CSV or GeoJSON format (memory-efficient)
|
||||||
|
- **ready**: File written to disk at filePath, TTL (expiresAt) set and ready for download
|
||||||
|
- **error**: Error message recorded, awaits manual retry via queue or TTL cleanup
|
||||||
|
- **Cleanup**: After file download completes, filePath cleared and status reset to pending for potential re-download
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. New Files
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
| File | Status | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `model/api_key.js` | ✅ Done | ApiKey Mongoose model |
|
||||||
|
| `model/export_job.js` | ✅ Done | ExportJob tracking model |
|
||||||
|
| `middlewares/app_validator.js` | ✅ Done | Added `checkApiKey` function + whitelist entry |
|
||||||
|
| `routes/api_pub.js` | ✅ Done | `/api/v1/` route definitions |
|
||||||
|
| `routes/api_keys.js` | ✅ Done | `/api/keys` route definitions |
|
||||||
|
| `routes/index.js` | ✅ Done | Registers `api_pub` and `api_keys` |
|
||||||
|
| `controllers/api_key.js` | ✅ Done | `createKey`, `listKeys`, `revokeKey` |
|
||||||
|
| `controllers/api_pub.js` | ✅ Done | `getSessions`, `getSessionRecords`, `getAreas` |
|
||||||
|
| `controllers/api_export.js` | ✅ Done | `triggerExport`, `getExportStatus`, `downloadExport` |
|
||||||
|
| `scripts/migrate_avg_spray_speed.js` | ✅ Done | One-time back-fill for existing jobs |
|
||||||
|
|
||||||
|
### Modified Files (existing)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `model/application.js` | Added `avgSpraySpeed: Number` field |
|
||||||
|
| `workers/job_worker.js` | Accumulates `avgSpraySpeed` during file import at lines ~528, ~944–1090, ~1309–1381 |
|
||||||
|
|
||||||
|
### Frontend (pending)
|
||||||
|
|
||||||
|
| File | Status | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `job-list.component.ts/.html` | ⬜ Pending | Add `orderNumber` filter input |
|
||||||
|
| `src/app/settings/api-keys/` | ⬜ Pending | API Key management feature module |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Model Designs
|
||||||
|
|
||||||
|
### 4.1 ApiKey (`model/api_key.js`)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `owner` | ObjectId → User | The applicator this key authorises |
|
||||||
|
| `label` | String | Human-readable name (max 100 chars) |
|
||||||
|
| `prefix` | String | First 8 chars of plain key — stored clear-text for O(1) candidate lookup |
|
||||||
|
| `keyHash` | String | `bcryptjs` hash of the full plain key — plain key never stored |
|
||||||
|
| `service` | `ApiKeyServices` | Which service the key grants access to: `'data_export'` (default) or `'partner_api'` |
|
||||||
|
| `active` | Boolean | Revoke by setting `false` |
|
||||||
|
| `managedBy` | `'owner'` \| `'admin'` | Who created the key |
|
||||||
|
| `createdAt` | Date | |
|
||||||
|
| `lastUsedAt` | Date | Updated async (fire-and-forget) — no added request latency |
|
||||||
|
|
||||||
|
**Key lookup flow:** `prefix` → find candidates → `bcrypt.compare(incomingKey, candidate.keyHash)` → match → set `req.uid = key.owner`.
|
||||||
|
|
||||||
|
**Limit:** 10 active keys per owner (enforced in `createKey`).
|
||||||
|
|
||||||
|
### 4.2 ExportJob (`model/export_job.js`)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `owner` | ObjectId → User | Scoped to requesting applicator |
|
||||||
|
| `jobId` | Number | AgMission job ID |
|
||||||
|
| `format` | `'csv'` \| `'geojson'` | Requested output format |
|
||||||
|
| `interval` | Number \| null | GPS point thinning in seconds; `null` = all points |
|
||||||
|
| `units` | `ExportUnits` | Output measurement system: `'metric'` (default) or `'us'` |
|
||||||
|
| `status` | `'pending'` \| `'processing'` \| `'ready'` \| `'error'` | Lifecycle state |
|
||||||
|
| `filePath` | String | Absolute path on disk (set when ready) |
|
||||||
|
| `errorMsg` | String | Populated on error |
|
||||||
|
| `createdAt` | Date | |
|
||||||
|
| `expiresAt` | Date | MongoDB TTL index — document auto-deleted after expiry |
|
||||||
|
|
||||||
|
Files are written to `env.TEMP_DIR`. TTL defaults to 24 hours (`EXPORT_TTL_HOURS` env var).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API Endpoint Reference
|
||||||
|
|
||||||
|
### 5.1 Authentication
|
||||||
|
|
||||||
|
All `/api/v1/` requests require:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: <full 64-char hex key>
|
||||||
|
```
|
||||||
|
|
||||||
|
No `Authorization` header needed. On failure the middleware returns `401`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 `GET /api/v1/jobs/:jobId/sessions`
|
||||||
|
|
||||||
|
Returns one summary record per uploaded application file ("session") for the job.
|
||||||
|
|
||||||
|
**Response shape (per session):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "...",
|
||||||
|
"fileName": "2507140724SatlocG4.log",
|
||||||
|
"startDateTime": "2025-07-14T10:24:00Z",
|
||||||
|
"endDateTime": "2025-07-14T11:05:42Z",
|
||||||
|
"totalFlightTime_s": 2462,
|
||||||
|
"totalSprayTime_s": 1840,
|
||||||
|
"totalTurnTime_s": 622,
|
||||||
|
"totalSprayed_ha": 48.3,
|
||||||
|
"totalSprayMat": 120.5,
|
||||||
|
"totalSprayMatUnit": 3,
|
||||||
|
"avgSpraySpeed_ms": 14.2,
|
||||||
|
"matType": "wet",
|
||||||
|
"appRate": 2.5,
|
||||||
|
"appRateUnit": "L/ha",
|
||||||
|
"flowController": "SatLoc G4",
|
||||||
|
"sprayOnLag_s": 0.2,
|
||||||
|
"sprayOffLag_s": 0.15,
|
||||||
|
"pulsesPerLiter": 1800,
|
||||||
|
"mappedArea_ha": 50.0,
|
||||||
|
"overSprayedPct": -3.4,
|
||||||
|
"sprayZoneName": "Field A North",
|
||||||
|
"sprayZoneArea_ha": 25.0,
|
||||||
|
"pilotId": "...",
|
||||||
|
"aircraftName": "Agrinova 01",
|
||||||
|
"aircraftTailNumber": "PR-XYZ",
|
||||||
|
"assignedDate": "2025-07-13T18:00:00Z",
|
||||||
|
"sessionPilotName": "João Silva",
|
||||||
|
"reportConfirmed": true,
|
||||||
|
"areaSize_ha": 50.0,
|
||||||
|
"coverage_ha": 48.3,
|
||||||
|
"appRateConfirmed": 2.5,
|
||||||
|
"sprayVolume": 120.75,
|
||||||
|
"useActualVolume": false,
|
||||||
|
"actualVolume": null,
|
||||||
|
"effectiveVolume": 120.75,
|
||||||
|
"useCustomWeather": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`reportConfirmed` Fallback Logic Diagram:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A{Is rptOp.coverage<br/>defined?}
|
||||||
|
A -->|Yes| B["reportConfirmed=true"]
|
||||||
|
A -->|No| C["reportConfirmed=false"]
|
||||||
|
|
||||||
|
B --> D["Use Report Settings<br/>values"]
|
||||||
|
C --> E["Compute from raw<br/>data"]
|
||||||
|
|
||||||
|
D --> F{useActualVol?}
|
||||||
|
E --> G{useActualVol?}
|
||||||
|
|
||||||
|
F -->|Yes| H["effective=actual"]
|
||||||
|
F -->|No| I["effective=coverage*rate"]
|
||||||
|
|
||||||
|
G -->|Yes| J["effective=computed"]
|
||||||
|
G -->|No| K["effective=computed"]
|
||||||
|
|
||||||
|
H --> L["Confirmed block"]
|
||||||
|
I --> L
|
||||||
|
J --> M["Fallback block"]
|
||||||
|
K --> M
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | `reportConfirmed: true` | `reportConfirmed: false` |
|
||||||
|
|---|---|---|
|
||||||
|
| `areaSize_ha` | `Job.rptOp.areaSize` | Sum of `sprayAreas[].properties.area` |
|
||||||
|
| `coverage_ha` | `Job.rptOp.coverage` | Sum of `App.totalSprayed` |
|
||||||
|
| `appRate` | `Job.rptOp.appRate` | `AppFile.meta.appRate` (first session) |
|
||||||
|
| `sprayVolume` | `coverage × appRate` (from rptOp) | Same formula using fallback values |
|
||||||
|
| `effectiveVolume` | `actualVol` if `useActualVol`, else `sprayVolume` | `sprayVolume` (fallback) |
|
||||||
|
| weather fields | `Job.weatherInfo.*` when `useCustWI=true` | omitted |
|
||||||
|
|
||||||
|
> When `reportConfirmed: false`, re-fetch this record after the applicator confirms in Report Settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 `GET /api/v1/jobs/:jobId/sessions/:fileId/records`
|
||||||
|
|
||||||
|
Per-point GPS trace records, cursor-paginated. Uses the same `paginateWithCursor` helper as the existing `filesdata_post`.
|
||||||
|
|
||||||
|
**Query parameters:**
|
||||||
|
|
||||||
|
| Param | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `startingAfter` | — | Cursor (`_id` of last record received) |
|
||||||
|
| `limit` | 500 | Max records per page (hard cap: 2000) |
|
||||||
|
| `interval` | — | Return one record per N seconds of GPS time (e.g. `1`, `5`, `10`) |
|
||||||
|
|
||||||
|
**Field groups per record:**
|
||||||
|
|
||||||
|
*GPS Data*: `timeUtc`, `lat`, `lon`, `utmX`, `utmY`, `alt`, `grSpeed`, `heading`, `xTrack`, `lockedLine`, `hdop`, `satsInView`, `correctionId`, `waasId`, `sprayStat`
|
||||||
|
|
||||||
|
*Application Info*: `flowRateApplied`, `flowRateRequired`, `appRateRequired`, `appRateApplied`*, `swathWidth`, `boomPressure_psi`, `sprayOnLag_s`†, `sprayOffLag_s`†, `pulsesPerLiter`†, `rpm[]`
|
||||||
|
|
||||||
|
*MET*: `windSpeed_ms`, `windDir_deg`, `temp_c`, `humidity_pct`
|
||||||
|
|
||||||
|
> \* `appRateApplied` is the only computed field: `lminApp / (grSpeed × swath) × 10000`. Returns `null` when `grSpeed = 0` or `swath = 0`.
|
||||||
|
> † Session constants from `AppFile.meta` — same value repeated on every record for flat-file consumers.
|
||||||
|
|
||||||
|
**Record Decoding Transformation Pipeline:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[Raw AppDetail] --> B[Interval Thinning]
|
||||||
|
B --> C[Decode GPS Fields]
|
||||||
|
C --> D[Compute appRateApplied]
|
||||||
|
D --> E[Inject Session Meta]
|
||||||
|
E --> F[Filter sprayStat=3]
|
||||||
|
F --> G[Format ISO 8601 UTC]
|
||||||
|
G --> H[Return API Record]
|
||||||
|
|
||||||
|
style A fill:#fce4ec
|
||||||
|
style B fill:#f3e5f5
|
||||||
|
style C fill:#e8eaf6
|
||||||
|
style D fill:#f3e5f5
|
||||||
|
style E fill:#e0f2f1
|
||||||
|
style F fill:#fce4ec
|
||||||
|
style G fill:#fff9c4
|
||||||
|
style H fill:#c8e6c9
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decoding rules:**
|
||||||
|
- `satsInView`: raw `satsIn > 99` → `satsIn − 100`
|
||||||
|
- `correctionId`: raw `tslu > 100` → `tslu − 100`
|
||||||
|
- `waasId`: only set when `calcodeFreq` in range 20001–29999 → `calcodeFreq − 20000`
|
||||||
|
- `sprayStat === 3` (spray segment **START** marker) is filtered out — it anchors the start position for the next area/distance computation but carries no application measurement; only spray-on records (`sprayStat !== 3 && sprayStat > 0`) represent actual application data
|
||||||
|
- `appRateApplied` = `lminApp / (grSpeed × swath) × 10000`; null when grSpeed or swath = 0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 Public API Endpoint Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph External[External Callers]
|
||||||
|
PBI[Power BI]
|
||||||
|
ARCGIS[ArcGIS]
|
||||||
|
DW[Data Warehouse]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph PublicAPI[Public API /api/v1]
|
||||||
|
SESSIONS[GET /sessions]
|
||||||
|
RECORDS[GET /records]
|
||||||
|
AREAS[GET /areas]
|
||||||
|
TRIGEXP[POST /export]
|
||||||
|
POLLEXP[GET /export-status]
|
||||||
|
DOWNLOAD[GET /download]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Internal[Backend Models]
|
||||||
|
APP[(App)]
|
||||||
|
APPFILE[(AppFile)]
|
||||||
|
APPDETAIL[(AppDetail)]
|
||||||
|
EXPORTJOB[(ExportJob)]
|
||||||
|
end
|
||||||
|
|
||||||
|
PBI --> SESSIONS
|
||||||
|
ARCGIS --> AREAS
|
||||||
|
DW --> DOWNLOAD
|
||||||
|
|
||||||
|
SESSIONS --> APP
|
||||||
|
RECORDS --> APPDETAIL
|
||||||
|
AREAS --> APP
|
||||||
|
TRIGEXP --> EXPORTJOB
|
||||||
|
POLLEXP --> EXPORTJOB
|
||||||
|
DOWNLOAD --> EXPORTJOB
|
||||||
|
|
||||||
|
SESSIONS --> APPFILE
|
||||||
|
RECORDS --> APPFILE
|
||||||
|
|
||||||
|
style External fill:#e3f2fd
|
||||||
|
style PublicAPI fill:#f3e5f5
|
||||||
|
style Internal fill:#e8f5e9
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 `GET /api/v1/jobs/:jobId/areas`
|
||||||
|
|
||||||
|
Returns the planned spray-area polygons as a GeoJSON `FeatureCollection`.
|
||||||
|
|
||||||
|
> Only implement / expose once customer confirms this is needed for ArcGIS layer import (pending).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 Async Export
|
||||||
|
|
||||||
|
**Trigger:**
|
||||||
|
```
|
||||||
|
POST /api/v1/jobs/:jobId/export
|
||||||
|
Body: { "format": "csv", "interval": 1, "units": "us" }
|
||||||
|
→ 202 { "exportId": "...", "status": "pending", "units": "us" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Body parameters:
|
||||||
|
| Parameter | Required | Values | Default |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `format` | Yes | `'csv'`, `'geojson'` | — |
|
||||||
|
| `interval` | No | seconds (e.g. `1`, `5`) | `null` (all points) |
|
||||||
|
| `units` | No | `'metric'` (`ExportUnits.METRIC`), `'us'` (`ExportUnits.US`) | `'metric'` |
|
||||||
|
|
||||||
|
**Poll:**
|
||||||
|
```
|
||||||
|
GET /api/v1/exports/:exportId
|
||||||
|
→ { "status": "processing" } (repeat)
|
||||||
|
→ { "status": "ready", "downloadUrl": "/api/v1/exports/:id/download" }
|
||||||
|
→ { "status": "error", "errorMsg": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Download:**
|
||||||
|
```
|
||||||
|
GET /api/v1/exports/:exportId/download
|
||||||
|
→ streams file with Content-Disposition: attachment
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSV structure:** one row per `AppDetail` record. All raw trace fields plus job/session header columns (`jobId`, `orderNumber`, `fileId`, `fileName`, `pilotName`) repeated on every row — no joins required for Power BI or data warehouse import. Column headers include unit suffix when `units='us'` (e.g. `groundSpeed_mph` vs `groundSpeed_ms`, `temp_f` vs `temp_c`).
|
||||||
|
|
||||||
|
**US unit conversions** (`units='us'`):
|
||||||
|
|
||||||
|
| Metric field | US field | Factor |
|
||||||
|
|---|---|---|
|
||||||
|
| `alt_m` | `alt_ft` | × 3.28084 |
|
||||||
|
| `groundSpeed_ms` | `groundSpeed_mph` | × 2.23694 |
|
||||||
|
| `crossTrackError_m` | `crossTrackError_ft` | × 3.28084 |
|
||||||
|
| `swathWidth_m` | `swathWidth_ft` | × 3.28084 |
|
||||||
|
| `flowRateApplied_Lmin` | `flowRateApplied_galMin` | × 0.264172 |
|
||||||
|
| `flowRateRequired_Lmin` | `flowRateRequired_galMin` | × 0.264172 |
|
||||||
|
| `appRateRequired_Lha` | `appRateRequired_galAc` | × 0.10694 |
|
||||||
|
| `appRateApplied_Lha` | `appRateApplied_galAc` | × 0.10694 |
|
||||||
|
| `windSpeed_ms` | `windSpeed_mph` | × 2.23694 |
|
||||||
|
| `temp_c` | `temp_f` | × 9/5 + 32 |
|
||||||
|
| `boomPressure_psi` | `boomPressure_psi` | already PSI — no conversion |
|
||||||
|
|
||||||
|
**Implementation:** Node.js `Transform` stream over `AppDetail` cursor → writes to `env.TEMP_DIR`. Keeps memory flat regardless of file size. `interval` thinning applied identically to the records endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.7 Key Management Endpoints (Web UI, JWT-authenticated)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/keys` | List active keys for the signed-in applicator |
|
||||||
|
| `POST` | `/api/keys` | Create a key — returns full plain key **once** in the response |
|
||||||
|
| `DELETE` | `/api/keys/:keyId` | Revoke a key (sets `active: false`) |
|
||||||
|
|
||||||
|
**Key management body (`POST /api/keys`):**
|
||||||
|
```json
|
||||||
|
{ "label": "Power BI Prod", "service": "data_export" }
|
||||||
|
```
|
||||||
|
`service` is optional and defaults to `'data_export'`. Valid values are defined in `ApiKeyServices` in `helpers/constants.js`.
|
||||||
|
|
||||||
|
Admin users may append `?ownerId=<ObjectId>` or include `ownerId` in the POST body to manage keys for another account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `avgSpraySpeed` — Storage Strategy
|
||||||
|
|
||||||
|
Rather than computing average spray speed on demand (which would require scanning all `AppDetail` records for every session summary request), it is computed once at **import time** and stored in `App.avgSpraySpeed`.
|
||||||
|
|
||||||
|
**Accumulation logic in `job_worker.js`:**
|
||||||
|
```javascript
|
||||||
|
// Per GPS point during file parsing (in importDataFiles, per-file pass):
|
||||||
|
// sprayStat=3 is the segment START marker — saves prevPos for next area calc but is
|
||||||
|
// not an actual spray record; exclude it from speed averaging.
|
||||||
|
if (record.sprayStat !== 3 && record.sprayStat > 0 && utils.isNumber(record.grSpeed)) {
|
||||||
|
totalSpeedAcc += record.grSpeed;
|
||||||
|
spraySpeedCount++;
|
||||||
|
}
|
||||||
|
// At end of file:
|
||||||
|
importInfo.avgSpraySpeed = spraySpeedCount > 0 ? totalSpeedAcc / spraySpeedCount : null; // m/s
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-time back-fill:** `scripts/migrate_avg_spray_speed.js` — iterates existing `App` docs via cursor, re-scans their `AppDetail` records, bulk-writes the value. Safe to run on production (cursor-based, low memory, progress logging every 100 docs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Frontend Design
|
||||||
|
|
||||||
|
### 7.1 Job List Filter Enhancement (Step 3 — pending)
|
||||||
|
|
||||||
|
**File:** `src/app/job/job-list/job-list.component.ts`
|
||||||
|
|
||||||
|
Add an `orderNumber` text filter control to the existing filter bar alongside client, status, and date pickers. Wire into the existing `Job.Fetch()` NgRx action that calls `jobService.loadJobs()`. Minor backend check: ensure `searchJobs_post` / `getJobs_get` accepts `orderNumber` as a partial-match filter.
|
||||||
|
|
||||||
|
### 7.2 API Key Management UI (Step 8 — pending)
|
||||||
|
|
||||||
|
New lazy-loaded feature module following the same NgRx pattern as `PartnerListComponent` / `ClientListComponent`.
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
src/app/settings/api-keys/
|
||||||
|
api-keys.module.ts
|
||||||
|
api-keys-routing.module.ts
|
||||||
|
api-keys-list/
|
||||||
|
api-keys-list.component.ts
|
||||||
|
api-keys-list.component.html
|
||||||
|
store/
|
||||||
|
api-key.actions.ts
|
||||||
|
api-key.reducer.ts
|
||||||
|
api-key.effects.ts
|
||||||
|
services/
|
||||||
|
api-key.service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**UX flow:**
|
||||||
|
1. PrimeNG `p-table` listing keys — columns: Label, Prefix, Created, Last Used, Status
|
||||||
|
2. "Generate Key" button → calls `POST /api/keys` → shows full key in a `p-dialog` with copy-to-clipboard — key masked after dialog is closed, never retrievable again
|
||||||
|
3. "Revoke" button per row → `p-confirmDialog` → calls `DELETE /api/keys/:id`
|
||||||
|
4. Admin view: additional applicator selector (`p-dropdown`) to manage keys on behalf of any account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementation Status
|
||||||
|
|
||||||
|
| Step | Feature | Status | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `App.avgSpraySpeed` — model field + import worker + migration script | ✅ Done | `model/application.js`, `workers/job_worker.js`, `scripts/migrate_avg_spray_speed.js` |
|
||||||
|
| 2 | `ApiKey` model + `checkApiKey` middleware + `/api/keys` CRUD | ✅ Done | `model/api_key.js`, `middlewares/app_validator.js`, `routes/api_keys.js`, `controllers/api_key.js`. `ApiKeyServices` frozen constant controls valid `service` values. |
|
||||||
|
| 3 | Job List UI filter enhancements | ⬜ Pending | Frontend only — `job-list.component` |
|
||||||
|
| 4 | `GET /api/v1/jobs/:id/sessions` — session summary | ✅ Done | `controllers/api_pub.js` `getSessions` |
|
||||||
|
| 5 | `GET /api/v1/jobs/:id/sessions/:fid/records` — raw trace | ✅ Done | `controllers/api_pub.js` `getSessionRecords` |
|
||||||
|
| 6 | `GET /api/v1/jobs/:id/areas` — spray-area GeoJSON | ✅ Done | `controllers/api_pub.js` `getAreas` — awaiting customer confirmation to expose |
|
||||||
|
| 7 | Async export (`POST /export`, `GET /exports/:id`, download) | ✅ Done | `model/export_job.js`, `controllers/api_export.js`. `ExportUnits` frozen constant controls valid `units` values; US unit conversions applied at output time. |
|
||||||
|
| 8 | API Key management UI (Angular) | ⬜ Pending | New `settings/api-keys` feature module |
|
||||||
|
| 9 | Sandbox seeding script | ⬜ Pending | `scripts/seed_sandbox.js` |
|
||||||
|
| — | Tests | ⬜ Pending | `checkApiKey` unit tests, session summary integration tests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Key Design Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|---|---|
|
||||||
|
| Separate `checkApiKey` middleware (not extending `checkUser`) | Zero risk to existing JWT-protected routes; `req.uid` set identically so all ownership filters work unchanged |
|
||||||
|
| `prefix` stored clear-text in `ApiKey` | O(1) candidate row lookup before expensive `bcrypt.compare`; prefix alone is not usable as a key |
|
||||||
|
| `ApiKeyServices` frozen constant for `service` enum | Single source of truth in `helpers/constants.js`; adding a new service type requires editing the constant only — model and controller stay in sync via `Object.values()` |
|
||||||
|
| `ExportUnits` frozen constant for `units` enum | Same principle — consistent with project convention for all enumeric text constants |
|
||||||
|
| `avgSpraySpeed` stored at import, not computed on demand | Session summary endpoint must never touch `AppDetail` (billion-scale collection); O(1) read from `App` model |
|
||||||
|
| Cursor pagination on `AppDetail._id` | Consistent with existing `filesdata_post` pattern; no skip-based offset that degrades on large collections |
|
||||||
|
| `interval` thinning on both records endpoint and export | Consistent behaviour; reduces Power BI payload for overview queries; daily batch export at 17:00 can use `interval=1` to shrink CSV size significantly |
|
||||||
|
| `reportConfirmed` boolean + always-populated fallback | Consumer's data warehouse always has a usable record; can upsert when field flips to `true` |
|
||||||
|
| Async export with TTL (`ExportJob.expiresAt` + MongoDB TTL index) | Files self-clean after 24 hours; no manual housekeeping job needed |
|
||||||
|
| CSV columns include job/session header repeated per row | Direct Power BI / warehouse import without requiring a separate join step |
|
||||||
|
| Unit conversion at output time, not at storage | Raw data stored in metric throughout; conversion applied in `recordToRow()` with unit-labelled column headers so output is self-documenting |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Constraints & Notes
|
||||||
|
|
||||||
|
- All API responses use **metric units by default** (ha, m/s, L/min, L/ha, Kg/ha, °C, metres). Callers may request US customary output via `units: 'us'` on the export endpoint — see Section 5.6.
|
||||||
|
- All dates/times are **ISO 8601 UTC strings**.
|
||||||
|
- Coordinates are **WGS84 decimal degrees** (EPSG:4326) — numerically equivalent to SIRGAS 2000 (EPSG:4674) for Brazil.
|
||||||
|
- `AppDetail.sprayStat === 3` is a spray segment **START** marker — it records the anchor position (UTM X/Y, swath, line number) that the worker uses to compute the area of the next spray segment. It carries no application measurement and is filtered from all public API responses. Spray-on application records are `sprayStat > 0 && sprayStat !== 3`.
|
||||||
|
- `AppDetail.raserAlt` (typo in source schema) is exposed as `laserAlt_m` in the API.
|
||||||
|
- `rpm[]` array semantics differ between liquid and dry material types — consumers must use `matType` from the session summary to interpret correctly.
|
||||||
|
- **Pending:** customer confirmation on whether `GET /api/v1/jobs/:id/areas` (spray-area GeoJSON) is required for their ArcGIS workflow — endpoint is implemented but not yet scheduled for release.
|
||||||
316
Development/server/docs/DYNAMIC_FILTER_GUIDE.md
Normal file
316
Development/server/docs/DYNAMIC_FILTER_GUIDE.md
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
# Dynamic Filter Guide
|
||||||
|
|
||||||
|
This document describes the end-to-end filtering system introduced to replace
|
||||||
|
bespoke per-field query parameters with a single, generic `filters` JSON param.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Dynamic Filter Guide](#dynamic-filter-guide)
|
||||||
|
- [Table of Contents](#table-of-contents)
|
||||||
|
- [1. Filter Data Structure](#1-filter-data-structure)
|
||||||
|
- [Fields per entry](#fields-per-entry)
|
||||||
|
- [2. How the Client Sends Filters](#2-how-the-client-sends-filters)
|
||||||
|
- [3. Server Helper: `buildDynamicFilter`](#3-server-helper-builddynamicfilter)
|
||||||
|
- [Signature](#signature)
|
||||||
|
- [Usage in a controller](#usage-in-a-controller)
|
||||||
|
- [Security properties](#security-properties)
|
||||||
|
- [4. Field Schema Types](#4-field-schema-types)
|
||||||
|
- [5. AND / OR Operator Evaluation](#5-and--or-operator-evaluation)
|
||||||
|
- [6. Using Filters in Another Component](#6-using-filters-in-another-component)
|
||||||
|
- [Step 1 — Define filter definitions (component TS)](#step-1--define-filter-definitions-component-ts)
|
||||||
|
- [Step 2 — Add the component to the template (HTML)](#step-2--add-the-component-to-the-template-html)
|
||||||
|
- [Step 3 — Handle the submit event (component TS)](#step-3--handle-the-submit-event-component-ts)
|
||||||
|
- [Step 4 — Pass `filters` through the service (service TS)](#step-4--pass-filters-through-the-service-service-ts)
|
||||||
|
- [Step 5 — Define the schema and call `buildDynamicFilter` (controller JS)](#step-5--define-the-schema-and-call-builddynamicfilter-controller-js)
|
||||||
|
- [7. Adding a New Filterable Field](#7-adding-a-new-filterable-field)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Filter Data Structure
|
||||||
|
|
||||||
|
The client produces a plain JSON object where **each key is a document field
|
||||||
|
name** and each value describes the filter to apply to that field.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client": {
|
||||||
|
"value": "69d7e6e36d2608f005a6e8b0",
|
||||||
|
"operator": "and",
|
||||||
|
"dataType": "select"
|
||||||
|
},
|
||||||
|
"orderNumber": {
|
||||||
|
"value": "123",
|
||||||
|
"operator": "and",
|
||||||
|
"valueOperator": "contains",
|
||||||
|
"dataType": "text"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"value": "1m",
|
||||||
|
"operator": "and",
|
||||||
|
"dataType": "date-preset"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fields per entry
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `value` | any | The filter value. Type depends on `dataType` (see below). |
|
||||||
|
| `operator` | `'and'` \| `'or'` | How this filter combines with the previous one (left-to-right, no precedence). |
|
||||||
|
| `valueOperator` | string | How `value` is compared against the document field (see [Field Schema Types](#4-field-schema-types)). **Only present for types that support multiple operators** (`text`, `date`, `number`). Absent for `select`, `select-multi`, and `date-preset`. |
|
||||||
|
| `dataType` | string | Client-side hint only — **ignored by the server**. The server determines the type from its own `fieldSchema` whitelist. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. How the Client Sends Filters
|
||||||
|
|
||||||
|
The `DynamicFilterComponent` emits a `FilterChangeEvent` via its `(filtersSubmit)` output:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface FilterChangeEvent {
|
||||||
|
filters: ActiveFilter[]; // raw active filter objects
|
||||||
|
query: Record<string, any>; // ready-to-serialise query (from buildFilterQuery())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`buildFilterQuery()` (in `dynamic-filter.component.ts`) converts `ActiveFilter[]`
|
||||||
|
into the JSON structure shown in Section 1.
|
||||||
|
|
||||||
|
The component/page that owns the list dispatches this to the store:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
onFiltersSubmit(event: FilterChangeEvent) {
|
||||||
|
const q = { ...event.query };
|
||||||
|
|
||||||
|
// Optional: inject a default for any required field not set by the user
|
||||||
|
if (!q.createdAt) {
|
||||||
|
q.createdAt = { value: '1m', operator: 'and', valueOperator: 'exact', dataType: 'date-preset' };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.dispatch(new myActions.Fetch({
|
||||||
|
jobsByPilot: this.authSvc.isPilotUser,
|
||||||
|
filters: JSON.stringify(q) // ← single param sent to the API
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Angular service appends it as a query parameter:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (ops?.filters != null) {
|
||||||
|
_ops = _ops.set('filters', ops.filters);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting request looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/jobs?jpo=false&filters=%7B%22orderNumber%22%3A%7B%22value%22...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Server Helper: `buildDynamicFilter`
|
||||||
|
|
||||||
|
**Location:** `server/helpers/dynamic_filter.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { buildDynamicFilter } = require('../helpers/dynamic_filter');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signature
|
||||||
|
|
||||||
|
```js
|
||||||
|
buildDynamicFilter(filtersJson, fieldSchema) → object
|
||||||
|
```
|
||||||
|
|
||||||
|
| Argument | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `filtersJson` | `string \| undefined` | Raw JSON string from `req.query['filters']`. |
|
||||||
|
| `fieldSchema` | `Object.<string, FieldType>` | Caller-defined server-side type map (see Section 4). **Return `{}` when absent.** |
|
||||||
|
|
||||||
|
Returns a MongoDB filter fragment suitable for `{ $match: dynFilter }`.
|
||||||
|
Returns `{}` if `filtersJson` is absent, unparseable, or produces no valid conditions.
|
||||||
|
|
||||||
|
### Usage in a controller
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { buildDynamicFilter } = require('../helpers/dynamic_filter');
|
||||||
|
|
||||||
|
// Define once at module level — server controls the type, not the client
|
||||||
|
const MY_FILTER_SCHEMA = {
|
||||||
|
name: 'text',
|
||||||
|
createdAt: 'date-preset',
|
||||||
|
status: 'numeric-enum', // stored as a number; coerces string values safely
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getItems_get(req, res) {
|
||||||
|
let baseFilter = { markedDelete: { $in: [null, false] } };
|
||||||
|
const dynFilter = buildDynamicFilter(req.query['filters'], MY_FILTER_SCHEMA);
|
||||||
|
|
||||||
|
const pipeline = [
|
||||||
|
{ $match: baseFilter },
|
||||||
|
...(Object.keys(dynFilter).length > 0 ? [{ $match: dynFilter }] : []),
|
||||||
|
// ... rest of pipeline
|
||||||
|
];
|
||||||
|
|
||||||
|
const items = await MyModel.aggregate(pipeline);
|
||||||
|
res.json(items);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security properties
|
||||||
|
|
||||||
|
- **`dataType` from the client is always ignored.** Field type is resolved from
|
||||||
|
the caller's `fieldSchema`. An attacker cannot change how a value is
|
||||||
|
interpreted by manipulating `dataType`.
|
||||||
|
- **Regex values are escaped** (via `value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`)
|
||||||
|
to prevent ReDoS attacks.
|
||||||
|
- **ObjectId values** are validated against `/^[a-f\d]{24}$/i` before being
|
||||||
|
cast, preventing injection through malformed IDs.
|
||||||
|
- Fields not present in `fieldSchema` are silently ignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Field Schema Types
|
||||||
|
|
||||||
|
The `fieldSchema` maps each field name to one of the following server-side types:
|
||||||
|
|
||||||
|
| Type | `valueOperator` values | MongoDB condition produced |
|
||||||
|
|---|---|---|
|
||||||
|
| `'text'` | `contains` \| `startsWith` \| `exact` | Regex: `/val/i`, `/^val/i`, `/^val$/i` |
|
||||||
|
| `'objectid-text'` | `contains` \| `startsWith` \| `exact` | `$expr: { $regexMatch: { input: { $toString: '$_id' }, ... } }` |
|
||||||
|
| `'objectid'` | — | `{ field: new ObjectId(value) }` |
|
||||||
|
| `'date'` | `before` \| `after` \| `exact` \| `range` | `$lte` / `$gte` / exact day range / date range |
|
||||||
|
| `'date-preset'` | — | Delegates to `mongoUtil.getDateFilter(value, field)` — supports `'1m'`, `'3m'`, `'6m'`, year strings (e.g. `'2025'`), ISO date strings, and `[start, end]` arrays |
|
||||||
|
| `'select'` | — | `{ field: value }` — value passed through as-is (use for string/objectid enum fields) |
|
||||||
|
| `'select-multi'` | — | `{ field: { $in: [...values] } }` for multiple selections, `{ field: value }` for a single selection — values passed through as-is |
|
||||||
|
| `'numeric-enum'` | — | Same as `select-multi` but coerces every value with `Number()` first, dropping non-numeric entries. Use for fields stored as numeric enums (e.g. `status`). |
|
||||||
|
| `'number'` | `exact` \| `greaterThan` \| `lessThan` | `{ field: n }` / `{ $gt: n }` / `{ $lt: n }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. AND / OR Operator Evaluation
|
||||||
|
|
||||||
|
Conditions are combined **left-to-right** with no precedence. Each filter
|
||||||
|
entry's `operator` field defines how it joins to the accumulated result so far.
|
||||||
|
|
||||||
|
```
|
||||||
|
A(and) B(and) C(or) D(and)
|
||||||
|
→ step 1: A
|
||||||
|
→ step 2: { $and: [A, B] }
|
||||||
|
→ step 3: { $or: [{ $and: [A, B] }, C] }
|
||||||
|
→ step 4: { $and: [{ $or: [{ $and: [A, B] }, C] }, D] }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `operator` field on the **first** entry is ignored (there is nothing to
|
||||||
|
combine it with).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Using Filters in Another Component
|
||||||
|
|
||||||
|
### Step 1 — Define filter definitions (component TS)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { FilterDefinition } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||||
|
|
||||||
|
myFilterDefinitions: FilterDefinition[] = [
|
||||||
|
{ key: 'name', label: 'Name', dataType: 'text' },
|
||||||
|
{ key: 'createdAt', label: 'Created Date', dataType: 'date-preset' },
|
||||||
|
// single-select: use 'select'
|
||||||
|
{ key: 'type', label: 'Type', dataType: 'select',
|
||||||
|
options: [{ label: 'Standard', value: 'standard' }, { label: 'Premium', value: 'premium' }] },
|
||||||
|
// multi-select: use 'select-multi'
|
||||||
|
{ key: 'status', label: 'Status', dataType: 'select-multi',
|
||||||
|
options: [{ label: 'Active', value: 1 }, { label: 'Inactive', value: 0 }] },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Add the component to the template (HTML)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<agm-dynamic-filter
|
||||||
|
[filterDefinitions]="myFilterDefinitions"
|
||||||
|
[locale]="locale"
|
||||||
|
[stateKey]="'my-list-filters'"
|
||||||
|
[defaultFilters]="[{ key: 'createdAt', value: '1m' }]"
|
||||||
|
(filtersChanged)="onFiltersChanged($event)"
|
||||||
|
(filtersSubmit)="onFiltersSubmit($event)">
|
||||||
|
</agm-dynamic-filter>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `[stateKey]` *(optional)* — a unique string key; active filters are saved to / restored
|
||||||
|
from `sessionStorage` under this key so they survive page navigations.
|
||||||
|
- `[defaultFilters]` *(optional)* — array of `{ key, value }` objects applied on first load
|
||||||
|
when no saved state is present.
|
||||||
|
- `(filtersChanged)` *(optional)* — fires on **every** value/operator change (reactive).
|
||||||
|
Use this to update live results without requiring an explicit submit.
|
||||||
|
- `(filtersSubmit)` — fires when the user presses the **Apply** button.
|
||||||
|
|
||||||
|
### Step 3 — Handle the submit event (component TS)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||||
|
|
||||||
|
onFiltersSubmit(event: FilterChangeEvent) {
|
||||||
|
this.store.dispatch(new myActions.Fetch({
|
||||||
|
filters: JSON.stringify(event.query)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Pass `filters` through the service (service TS)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
loadItems(ops: any): Observable<IItem[]> {
|
||||||
|
let params = new HttpParams().set('jpo', ops?.jobsByPilot || 'false');
|
||||||
|
if (ops?.filters != null) {
|
||||||
|
params = params.set('filters', ops.filters);
|
||||||
|
}
|
||||||
|
return this.http.get<IItem[]>(this.itemURL, { params });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5 — Define the schema and call `buildDynamicFilter` (controller JS)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { buildDynamicFilter } = require('../helpers/dynamic_filter');
|
||||||
|
|
||||||
|
const MY_FILTER_SCHEMA = {
|
||||||
|
name: 'text',
|
||||||
|
createdAt: 'date-preset',
|
||||||
|
type: 'select', // string field, single value
|
||||||
|
status: 'numeric-enum', // numeric field, one or more values
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getItems_get(req, res) {
|
||||||
|
const baseFilter = { markedDelete: { $in: [null, false] } };
|
||||||
|
const dynFilter = buildDynamicFilter(req.query['filters'], MY_FILTER_SCHEMA);
|
||||||
|
|
||||||
|
const items = await MyModel.aggregate([
|
||||||
|
{ $match: baseFilter },
|
||||||
|
...(Object.keys(dynFilter).length > 0 ? [{ $match: dynFilter }] : []),
|
||||||
|
]);
|
||||||
|
res.json(items);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Adding a New Filterable Field
|
||||||
|
|
||||||
|
1. **Client** — add a `FilterDefinition` entry to the component's
|
||||||
|
`filterDefinitions` array with the appropriate `dataType`.
|
||||||
|
2. **Server** — add the field and its type to the controller's `fieldSchema`
|
||||||
|
constant. Choose from the types in Section 4.
|
||||||
|
3. **No changes** to `buildDynamicFilter` or `DynamicFilterComponent` are
|
||||||
|
needed unless you require a new field type.
|
||||||
|
|
||||||
|
If you need a new field type (e.g. `'boolean'`), add a branch to
|
||||||
|
`buildSingleCondition` in `server/helpers/dynamic_filter.js` and add the
|
||||||
|
corresponding `valueOperator` options to `VALUE_OPERATOR_OPTIONS` in
|
||||||
|
`client/src/app/shared/dynamic-filter/dynamic-filter.component.ts`.
|
||||||
382
Development/server/docs/Data_Export_API.postman_collection.json
Normal file
382
Development/server/docs/Data_Export_API.postman_collection.json
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"name": "AgMission — Data Export API",
|
||||||
|
"description": "End-to-end testing collection for the Data Export API.\n\n## Setup\n1. Set the `baseUrl` variable to your server (e.g. `https://localhost:4100`).\n2. Log in via **[Auth] Login** — the `jwt` variable is captured automatically.\n3. Use **[Keys] Create API Key** — the `apiKey` variable is captured automatically.\n4. Set `jobId` and `fileId` to real IDs from your database.\n\n## Folders\n- **[Auth]** — Get a JWT for key-management endpoints\n- **[Keys]** — Manage API keys (`/api/keys`, JWT-protected)\n- **[Public API]** — Data export endpoints (`/api/v1/`, X-API-Key protected)\n- **[Export Workflow]** — Full async export flow (trigger → poll → download)",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"variable": [
|
||||||
|
{ "key": "baseUrl", "value": "https://localhost:4100", "type": "string" },
|
||||||
|
{ "key": "jwt", "value": "", "type": "string", "description": "Captured automatically by the Login request" },
|
||||||
|
{ "key": "apiKey", "value": "", "type": "string", "description": "Captured automatically by Create API Key" },
|
||||||
|
{ "key": "keyId", "value": "", "type": "string", "description": "Captured automatically by Create API Key" },
|
||||||
|
{ "key": "jobId", "value": "12345", "type": "string", "description": "AgMission job ID (integer)" },
|
||||||
|
{ "key": "fileId", "value": "", "type": "string", "description": "AppFile _id — captured from Get Sessions response" },
|
||||||
|
{ "key": "exportId", "value": "", "type": "string", "description": "Captured automatically by Trigger Export" }
|
||||||
|
],
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "[Auth]",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Login (get JWT)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"const r = pm.response.json();",
|
||||||
|
"if (r && r.token) {",
|
||||||
|
" pm.collectionVariables.set('jwt', r.token);",
|
||||||
|
" console.log('JWT captured');",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [{ "key": "Content-Type", "value": "application/json" }],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"username\": \"your@email.com\",\n \"password\": \"yourpassword\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/users/login",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "users", "login"]
|
||||||
|
},
|
||||||
|
"description": "Standard AgMission login. Stores the returned JWT in the `jwt` collection variable for subsequent key-management requests."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[Keys] API Key Management",
|
||||||
|
"description": "JWT-protected endpoints for managing API keys. Pass the JWT from the Login request in the Authorization header.",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Create API Key",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"const r = pm.response.json();",
|
||||||
|
"if (r && r.key) {",
|
||||||
|
" pm.collectionVariables.set('apiKey', r.key);",
|
||||||
|
" pm.collectionVariables.set('keyId', r._id);",
|
||||||
|
" console.log('API Key captured — save it now, it will not be shown again:', r.key);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{ "key": "Content-Type", "value": "application/json" },
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{jwt}}" }
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"label\": \"Postman Test Key\",\n \"service\": \"data_export\"\n}",
|
||||||
|
"options": { "raw": { "language": "json" } }
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/keys",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "keys"]
|
||||||
|
},
|
||||||
|
"description": "Creates a new API key. The plain `key` field is returned **once** — the test script captures it to `apiKey`. `service` can be `data_export` (default) or `partner_api`."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "List API Keys",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{jwt}}" }
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/keys",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "keys"]
|
||||||
|
},
|
||||||
|
"description": "Returns all keys (active and revoked) for the authenticated applicator. Admins can append `?ownerId=<ObjectId>` to list another account's keys."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Revoke API Key",
|
||||||
|
"request": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{jwt}}" }
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/keys/{{keyId}}",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "keys", "{{keyId}}"]
|
||||||
|
},
|
||||||
|
"description": "Soft-deletes the key (sets `active: false`). Uses the `keyId` variable captured by Create API Key."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create API Key (Admin — on behalf of owner)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{ "key": "Content-Type", "value": "application/json" },
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{jwt}}" }
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"label\": \"Partner Integration Key\",\n \"service\": \"partner_api\",\n \"ownerId\": \"<applicator_user_id>\"\n}",
|
||||||
|
"options": { "raw": { "language": "json" } }
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/keys",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "keys"]
|
||||||
|
},
|
||||||
|
"description": "Admin-only. Creates a key for a different account by supplying `ownerId`. Key will have `managedBy: 'admin'`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[Public API] /api/v1",
|
||||||
|
"description": "External data-export endpoints. All require the `X-API-Key` header with the full 64-char hex key captured by Create API Key.",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Sessions (job summary)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"const r = pm.response.json();",
|
||||||
|
"// Capture first fileId for the records endpoint",
|
||||||
|
"if (r && r.sessions && r.sessions.length > 0) {",
|
||||||
|
" pm.collectionVariables.set('fileId', r.sessions[0].fileId);",
|
||||||
|
" console.log('fileId captured:', r.sessions[0].fileId);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{ "key": "X-API-Key", "value": "{{apiKey}}" }
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/sessions",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "v1", "jobs", "{{jobId}}", "sessions"]
|
||||||
|
},
|
||||||
|
"description": "Returns one session summary per uploaded application file for the job.\n\n- `reportConfirmed: false` means the applicator has not yet confirmed values in Report Settings — re-fetch when this flips.\n- `avgSpraySpeed` is in m/s (metric) or mph (US — not applicable to this endpoint, metric only).\n- Captures the first `fileId` for use in the records endpoint."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Session Records (raw GPS trace)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{ "key": "X-API-Key", "value": "{{apiKey}}" }
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/sessions/{{fileId}}/records?limit=100&interval=1",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "v1", "jobs", "{{jobId}}", "sessions", "{{fileId}}", "records"],
|
||||||
|
"query": [
|
||||||
|
{ "key": "limit", "value": "100", "description": "Max records per page (default 500, max 2000)" },
|
||||||
|
{ "key": "interval", "value": "1", "description": "Return one record per N seconds of GPS time (thinning). Remove for all points." },
|
||||||
|
{ "key": "startingAfter", "value": "", "description": "Cursor for next page — use _id from last record of previous page", "disabled": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Paginated raw GPS trace for one session file. Cursor-based pagination using `startingAfter=<record._id>`.\n\n`sprayStat` values:\n- `0` = spray off\n- `1` / `2` = spray on (application data)\n- `3` = segment START marker (filtered out automatically)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Spray Areas (GeoJSON)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{ "key": "X-API-Key", "value": "{{apiKey}}" }
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/areas",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "v1", "jobs", "{{jobId}}", "areas"]
|
||||||
|
},
|
||||||
|
"description": "Returns a GeoJSON FeatureCollection of planned spray-area polygons for the job. Suitable for ArcGIS / QGIS import."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[Export Workflow] Async Bulk Export",
|
||||||
|
"description": "Full async export flow: POST trigger → GET poll until ready → GET download.\n\nRun in order:\n1. Trigger Export\n2. Poll Export Status (repeat until `status` = `ready`)\n3. Download Export File",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "1 — Trigger Export (CSV, metric)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"const r = pm.response.json();",
|
||||||
|
"if (r && r.exportId) {",
|
||||||
|
" pm.collectionVariables.set('exportId', r.exportId);",
|
||||||
|
" console.log('exportId captured:', r.exportId);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{ "key": "Content-Type", "value": "application/json" },
|
||||||
|
{ "key": "X-API-Key", "value": "{{apiKey}}" }
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"format\": \"csv\",\n \"interval\": 1,\n \"units\": \"metric\"\n}",
|
||||||
|
"options": { "raw": { "language": "json" } }
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/export",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "v1", "jobs", "{{jobId}}", "export"]
|
||||||
|
},
|
||||||
|
"description": "Triggers async export generation. Returns `exportId` immediately (status = `pending`).\n\nBody fields:\n- `format`: `csv` | `geojson`\n- `interval`: GPS thinning in seconds (omit for all points)\n- `units`: `metric` (default) | `us`"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "1 — Trigger Export (CSV, US units)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"const r = pm.response.json();",
|
||||||
|
"if (r && r.exportId) {",
|
||||||
|
" pm.collectionVariables.set('exportId', r.exportId);",
|
||||||
|
" console.log('exportId captured (US units):', r.exportId);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{ "key": "Content-Type", "value": "application/json" },
|
||||||
|
{ "key": "X-API-Key", "value": "{{apiKey}}" }
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"format\": \"csv\",\n \"interval\": 1,\n \"units\": \"us\"\n}",
|
||||||
|
"options": { "raw": { "language": "json" } }
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/export",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "v1", "jobs", "{{jobId}}", "export"]
|
||||||
|
},
|
||||||
|
"description": "Same as CSV metric but with `units: 'us'`. Column headers will use US unit suffixes (e.g. `groundSpeed_mph`, `alt_ft`, `temp_f`, `appRateApplied_galAc`)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "1 — Trigger Export (GeoJSON)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"const r = pm.response.json();",
|
||||||
|
"if (r && r.exportId) {",
|
||||||
|
" pm.collectionVariables.set('exportId', r.exportId);",
|
||||||
|
" console.log('exportId captured (GeoJSON):', r.exportId);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{ "key": "Content-Type", "value": "application/json" },
|
||||||
|
{ "key": "X-API-Key", "value": "{{apiKey}}" }
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"format\": \"geojson\"\n}",
|
||||||
|
"options": { "raw": { "language": "json" } }
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/export",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "v1", "jobs", "{{jobId}}", "export"]
|
||||||
|
},
|
||||||
|
"description": "Triggers a GeoJSON FeatureCollection export with all GPS points (no thinning)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2 — Poll Export Status",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"const r = pm.response.json();",
|
||||||
|
"console.log('Export status:', r.status, '| units:', r.units, '| format:', r.format);",
|
||||||
|
"if (r.status === 'ready') {",
|
||||||
|
" console.log('Ready to download:', r.downloadUrl);",
|
||||||
|
"} else if (r.status === 'error') {",
|
||||||
|
" console.error('Export failed:', r.error);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{ "key": "X-API-Key", "value": "{{apiKey}}" }
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/exports/{{exportId}}",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "v1", "exports", "{{exportId}}"]
|
||||||
|
},
|
||||||
|
"description": "Poll until `status` = `ready`. When ready, the response includes `downloadUrl`. Possible statuses: `pending` | `processing` | `ready` | `error`."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3 — Download Export File",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{ "key": "X-API-Key", "value": "{{apiKey}}" }
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/v1/exports/{{exportId}}/download",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "v1", "exports", "{{exportId}}", "download"]
|
||||||
|
},
|
||||||
|
"description": "Streams the generated file with `Content-Disposition: attachment`. The file is deleted from disk after streaming (TTL = 24 hours). Only works when status = `ready`.\n\nIn Postman, use **Save Response → Save to a file** to download."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -266,6 +266,18 @@ const MatTypes = Object.freeze({
|
|||||||
DRY: 'dry'
|
DRY: 'dry'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API key service types — which service/integration a key grants access to
|
||||||
|
const ApiKeyServices = Object.freeze({
|
||||||
|
DATA_EXPORT: 'data_export', // External data consumers (Power BI, ArcGIS, data warehouses)
|
||||||
|
PARTNER_API: 'partner_api' // Partner system integrations
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export measurement unit systems
|
||||||
|
const ExportUnits = Object.freeze({
|
||||||
|
METRIC: 'metric', // SI units (m/s, L/min, L/ha, m, °C) — system default
|
||||||
|
US: 'us' // US customary (mph, gal/min, gal/ac, ft, °F)
|
||||||
|
});
|
||||||
|
|
||||||
// Partner authentication method constants
|
// Partner authentication method constants
|
||||||
const AuthMethods = Object.freeze({
|
const AuthMethods = Object.freeze({
|
||||||
API_KEY: 'api_key',
|
API_KEY: 'api_key',
|
||||||
@ -303,5 +315,6 @@ const emailRegex = RegExp(/^(([^<>\(\)\[\]\\.,;:\s@"]+(\.[^<>\(\)\[\]\\.,;:\s@"]
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
APTypes, Units, RateUnits, HttpStatus, Fields, RecTypes, UserTypes, FCTypes, DataTypes, MatTypes, Errors, AppStatus, AppProStatus, AssignStatus, TrialTypes,
|
APTypes, Units, RateUnits, HttpStatus, Fields, RecTypes, UserTypes, FCTypes, DataTypes, MatTypes, Errors, AppStatus, AppProStatus, AssignStatus, TrialTypes,
|
||||||
DEFAULT_LANG, DEL_APP_IDS, DEFAULT_TRIAL_DAYS, LIMIT_FILE_SIZE_ERR, InvoiceStatus, CostingItemType, InvCreateOption, PaymentMethod, ExportType, jobInvoiceEditRoles, jobInvoiceViewRoles, InvoiceStatusAction, ApplicationTypes, RefSources, emailRegex, SyncStatus, HealthStatus, PartnerOperations, PartnerTasks, SystemTypes, AuthMethods, PartnerCodes, PartnerLogTrackerStatus,
|
DEFAULT_LANG, DEL_APP_IDS, DEFAULT_TRIAL_DAYS, LIMIT_FILE_SIZE_ERR, InvoiceStatus, CostingItemType, InvCreateOption, PaymentMethod, ExportType, jobInvoiceEditRoles, jobInvoiceViewRoles, InvoiceStatusAction, ApplicationTypes, RefSources, emailRegex, SyncStatus, HealthStatus, PartnerOperations, PartnerTasks, SystemTypes, AuthMethods, PartnerCodes, PartnerLogTrackerStatus,
|
||||||
PartnerFileExtensions, PromoModes, APIActions, PromoEligibility, CouponDuration, StripeErrorTypes
|
PartnerFileExtensions, PromoModes, APIActions, PromoEligibility, CouponDuration, StripeErrorTypes,
|
||||||
|
ApiKeyServices, ExportUnits
|
||||||
};
|
};
|
||||||
|
|||||||
150
Development/server/helpers/dynamic_filter.js
Normal file
150
Development/server/helpers/dynamic_filter.js
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const moment = require('moment');
|
||||||
|
const mongoUtil = require('./mongo');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a MongoDB condition object for a single filter entry.
|
||||||
|
* Returns null if the value is invalid/empty for the given type.
|
||||||
|
*
|
||||||
|
* @param {string} key - Document field name
|
||||||
|
* @param {string} fieldType - Server-side type from the caller's fieldSchema
|
||||||
|
* @param {*} value - Filter value
|
||||||
|
* @param {string} valueOperator - e.g. 'contains', 'before', 'exact', ...
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
function buildSingleCondition(key, fieldType, value, valueOperator) {
|
||||||
|
if (fieldType === 'text') {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) return null;
|
||||||
|
// Escape special regex characters to prevent ReDoS
|
||||||
|
const safeVal = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').trim();
|
||||||
|
if (valueOperator === 'startsWith') return { [key]: new RegExp('^' + safeVal, 'i') };
|
||||||
|
if (valueOperator === 'exact') return { [key]: new RegExp('^' + safeVal + '$', 'i') };
|
||||||
|
return { [key]: new RegExp(safeVal, 'i') }; // contains
|
||||||
|
|
||||||
|
} else if (fieldType === 'objectid-text') {
|
||||||
|
// _id is an ObjectId — match against its string representation via $expr
|
||||||
|
if (typeof value !== 'string' || !value.trim()) return null;
|
||||||
|
const safeVal = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').trim();
|
||||||
|
const regexStr = valueOperator === 'startsWith' ? '^' + safeVal
|
||||||
|
: valueOperator === 'exact' ? '^' + safeVal + '$'
|
||||||
|
: safeVal;
|
||||||
|
return { $expr: { $regexMatch: { input: { $toString: '$_id' }, regex: regexStr, options: 'i' } } };
|
||||||
|
|
||||||
|
} else if (fieldType === 'date-preset') {
|
||||||
|
// Reuse existing getDateFilter which handles '1m', '3m', year strings, ISO dates
|
||||||
|
if (!value) return null;
|
||||||
|
const dateF = mongoUtil.getDateFilter(value, key);
|
||||||
|
return Object.keys(dateF).length > 0 ? dateF : null;
|
||||||
|
|
||||||
|
} else if (fieldType === 'date') {
|
||||||
|
if (!value) return null;
|
||||||
|
const rawVal = Array.isArray(value) ? value[0] : value;
|
||||||
|
if (valueOperator === 'before') {
|
||||||
|
const d = moment.utc(rawVal);
|
||||||
|
return d.isValid() ? { [key]: { $lte: d.endOf('day').toDate() } } : null;
|
||||||
|
} else if (valueOperator === 'after') {
|
||||||
|
const d = moment.utc(rawVal);
|
||||||
|
return d.isValid() ? { [key]: { $gte: d.startOf('day').toDate() } } : null;
|
||||||
|
} else if (valueOperator === 'range' && Array.isArray(value) && value.length >= 2) {
|
||||||
|
const isoArr = value.map(v => (v instanceof Date ? v.toISOString() : String(v)));
|
||||||
|
const dateF = mongoUtil.getDateFilter(isoArr, key);
|
||||||
|
return Object.keys(dateF).length > 0 ? dateF : null;
|
||||||
|
} else { // exact
|
||||||
|
const isoVal = rawVal instanceof Date ? rawVal.toISOString() : String(rawVal);
|
||||||
|
const dateF = mongoUtil.getDateFilter(isoVal, key);
|
||||||
|
return Object.keys(dateF).length > 0 ? dateF : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (fieldType === 'select') {
|
||||||
|
// Single-value exact match — value passed through as-is
|
||||||
|
if (value == null) return null;
|
||||||
|
return { [key]: value };
|
||||||
|
|
||||||
|
} else if (fieldType === 'select-multi') {
|
||||||
|
// Multi-value — accept either a single value or an array, produce $in
|
||||||
|
const arr = Array.isArray(value) ? value : [value];
|
||||||
|
const valid = arr.filter(v => v != null);
|
||||||
|
if (valid.length === 0) return null;
|
||||||
|
return valid.length === 1 ? { [key]: valid[0] } : { [key]: { $in: valid } };
|
||||||
|
|
||||||
|
} else if (fieldType === 'numeric-enum') {
|
||||||
|
// Like select-multi but coerces all values to Number first.
|
||||||
|
// Use for fields stored as numeric enums (e.g. status) where the client
|
||||||
|
// may send strings or numbers depending on serialisation.
|
||||||
|
const arr = Array.isArray(value) ? value : [value];
|
||||||
|
const valid = arr.map(Number).filter(n => !isNaN(n));
|
||||||
|
if (valid.length === 0) return null;
|
||||||
|
return valid.length === 1 ? { [key]: valid[0] } : { [key]: { $in: valid } };
|
||||||
|
|
||||||
|
} else if (fieldType === 'number') {
|
||||||
|
const n = Number(value);
|
||||||
|
if (isNaN(n)) return null;
|
||||||
|
if (valueOperator === 'greaterThan') return { [key]: { $gt: n } };
|
||||||
|
if (valueOperator === 'lessThan') return { [key]: { $lt: n } };
|
||||||
|
return { [key]: n }; // exact
|
||||||
|
|
||||||
|
} else if (fieldType === 'objectid') {
|
||||||
|
// Exact ObjectId match — validate 24-char hex to prevent injection
|
||||||
|
const id = String(value);
|
||||||
|
if (!/^[a-f\d]{24}$/i.test(id)) return null;
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
return { [key]: new ObjectId(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a MongoDB filter object from the serialised `filters` query param produced
|
||||||
|
* by the client-side `buildFilterQuery()` utility.
|
||||||
|
*
|
||||||
|
* The field type is resolved from a server-side whitelist so that the client
|
||||||
|
* cannot influence how a value is interpreted (security).
|
||||||
|
*
|
||||||
|
* Regex inputs are escaped to prevent ReDoS attacks.
|
||||||
|
*
|
||||||
|
* Operators are evaluated left-to-right (no precedence): each filter's `operator`
|
||||||
|
* ('and'|'or') defines how it combines with the accumulated result so far.
|
||||||
|
* Example: A(and) B(and) C(or) D(and) → ((A ∧ B) ∨ C) ∧ D
|
||||||
|
*
|
||||||
|
* @param {string|undefined} filtersJson - JSON stringified query object
|
||||||
|
* @param {Object.<string, 'text'|'objectid-text'|'objectid'|'date'|'date-preset'|'select'|'select-multi'|'numeric-enum'|'number'>} fieldSchema
|
||||||
|
* Maps each allowed field name to its server-side type. Only fields present
|
||||||
|
* in this schema are ever applied to the query — anything else is ignored.
|
||||||
|
* @returns {object} MongoDB filter fragment ready to pass into $match
|
||||||
|
*/
|
||||||
|
function buildDynamicFilter(filtersJson, fieldSchema) {
|
||||||
|
if (!filtersJson || !fieldSchema) return {};
|
||||||
|
let parsedFilters;
|
||||||
|
try { parsedFilters = JSON.parse(filtersJson); } catch (_) { return {}; }
|
||||||
|
if (!parsedFilters || typeof parsedFilters !== 'object' || Array.isArray(parsedFilters)) return {};
|
||||||
|
|
||||||
|
// Build an ordered list of valid conditions; JSON preserves insertion order
|
||||||
|
const conditions = [];
|
||||||
|
for (const [key, entry] of Object.entries(parsedFilters)) {
|
||||||
|
const fieldType = fieldSchema[key];
|
||||||
|
if (!fieldType || !entry || entry.value == null) continue;
|
||||||
|
|
||||||
|
const cond = buildSingleCondition(key, fieldType, entry.value, entry.valueOperator);
|
||||||
|
if (cond) {
|
||||||
|
conditions.push({ condition: cond, operator: entry.operator || 'and' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length === 0) return {};
|
||||||
|
if (conditions.length === 1) return conditions[0].condition;
|
||||||
|
|
||||||
|
// Combine left-to-right: entry[i].operator joins entry[i] to the accumulated result
|
||||||
|
let result = conditions[0].condition;
|
||||||
|
for (let i = 1; i < conditions.length; i++) {
|
||||||
|
const { condition, operator } = conditions[i];
|
||||||
|
result = operator === 'or'
|
||||||
|
? { $or: [result, condition] }
|
||||||
|
: { $and: [result, condition] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildDynamicFilter };
|
||||||
@ -129,6 +129,7 @@ module.exports = {
|
|||||||
QUEUE_NAME_PARTNER: getQueueName(process.env.QUEUE_NAME_PARTNER, 'partner_tasks'),
|
QUEUE_NAME_PARTNER: getQueueName(process.env.QUEUE_NAME_PARTNER, 'partner_tasks'),
|
||||||
|
|
||||||
REDIS_PWD: process.env.REDIS_PWD,
|
REDIS_PWD: process.env.REDIS_PWD,
|
||||||
|
JOBS_CACHE_TTL: Number(process.env.JOBS_CACHE_TTL) || 60, // seconds; set to 0 to disable
|
||||||
|
|
||||||
SMTP_HOST: process.env.SMTP_HOST,
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
SMTP_PORT: process.env.SMTP_PORT,
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
|
|||||||
@ -195,6 +195,80 @@ class RedisCache {
|
|||||||
(now - authData.lastHealthCheck) < healthCheckInterval;
|
(now - authData.lastHealthCheck) < healthCheckInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic get — retrieves and JSON-parses a value from cache.
|
||||||
|
* Falls back to in-memory cache when Redis is unavailable.
|
||||||
|
* @param {string} key
|
||||||
|
* @returns {Promise<any|null>}
|
||||||
|
*/
|
||||||
|
async get(key) {
|
||||||
|
if (this.isConnected) {
|
||||||
|
try {
|
||||||
|
const raw = await this.redis.get(key);
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
pino.error({ err }, `Redis get failed for key ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mem = this.fallbackCache.get(key);
|
||||||
|
if (mem) {
|
||||||
|
if (!mem._expiresAt || mem._expiresAt > Date.now()) return mem._value;
|
||||||
|
this.fallbackCache.delete(key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic set — JSON-serialises and stores a value in cache.
|
||||||
|
* Falls back to in-memory cache when Redis is unavailable.
|
||||||
|
* @param {string} key
|
||||||
|
* @param {any} value
|
||||||
|
* @param {number} ttlSeconds - Default 60 s
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async set(key, value, ttlSeconds = 60) {
|
||||||
|
if (this.isConnected) {
|
||||||
|
try {
|
||||||
|
await this.redis.setex(key, ttlSeconds, JSON.stringify(value));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
pino.error({ err }, `Redis set failed for key ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.fallbackCache.set(key, { _value: value, _expiresAt: Date.now() + ttlSeconds * 1000 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all cache keys whose names start with the given prefix pattern
|
||||||
|
* (supports a trailing wildcard, e.g. 'jobs:list:abc123:*').
|
||||||
|
* @param {string} pattern
|
||||||
|
* @returns {Promise<number>} number of entries removed
|
||||||
|
*/
|
||||||
|
async delByPattern(pattern) {
|
||||||
|
let count = 0;
|
||||||
|
if (this.isConnected) {
|
||||||
|
try {
|
||||||
|
const keys = await this.redis.keys(pattern);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.redis.del(...keys);
|
||||||
|
count += keys.length;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
pino.error({ err }, `Redis delByPattern failed for pattern ${pattern}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also purge the in-memory fallback
|
||||||
|
const prefix = pattern.endsWith('*') ? pattern.slice(0, -1) : pattern;
|
||||||
|
for (const key of this.fallbackCache.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
this.fallbackCache.delete(key);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close Redis connection
|
* Close Redis connection
|
||||||
*/
|
*/
|
||||||
|
|||||||
BIN
Development/server/job-uploads/AMS - 421.zip
Normal file
BIN
Development/server/job-uploads/AMS - 421.zip
Normal file
Binary file not shown.
BIN
Development/server/job-uploads/Colono.zip
Normal file
BIN
Development/server/job-uploads/Colono.zip
Normal file
Binary file not shown.
BIN
Development/server/job-uploads/Dynamic - Drift.zip
Normal file
BIN
Development/server/job-uploads/Dynamic - Drift.zip
Normal file
Binary file not shown.
BIN
Development/server/job-uploads/FKMCD - GFC - Larv.zip
Normal file
BIN
Development/server/job-uploads/FKMCD - GFC - Larv.zip
Normal file
Binary file not shown.
BIN
Development/server/job-uploads/Glynn May25.zip
Normal file
BIN
Development/server/job-uploads/Glynn May25.zip
Normal file
Binary file not shown.
BIN
Development/server/job-uploads/Helico - UFC AgFlow.zip
Normal file
BIN
Development/server/job-uploads/Helico - UFC AgFlow.zip
Normal file
Binary file not shown.
BIN
Development/server/job-uploads/JBI - AutoCal.zip
Normal file
BIN
Development/server/job-uploads/JBI - AutoCal.zip
Normal file
Binary file not shown.
BIN
Development/server/job-uploads/ShowMe - AgFlow.zip
Normal file
BIN
Development/server/job-uploads/ShowMe - AgFlow.zip
Normal file
Binary file not shown.
@ -242,8 +242,8 @@ async function checkApiKey(req, res, next) {
|
|||||||
req.uid = matched.owner.toString();
|
req.uid = matched.owner.toString();
|
||||||
req.apiKeyId = matched._id;
|
req.apiKeyId = matched._id;
|
||||||
|
|
||||||
// Fire-and-forget lastUsedAt update (do not await — avoids adding DB latency to request)
|
// Fire-and-forget lastUsedAt + requestCount update (do not await — avoids adding DB latency to request)
|
||||||
ApiKey.updateOne({ _id: matched._id }, { $set: { lastUsedAt: new Date() } }).catch(() => {});
|
ApiKey.updateOne({ _id: matched._id }, { $set: { lastUsedAt: new Date() }, $inc: { requestCount: 1 } }).catch(() => {});
|
||||||
|
|
||||||
// Load owner's userInfo from cache (same path as checkUser)
|
// Load owner's userInfo from cache (same path as checkUser)
|
||||||
const userInfo = cache.get(req.uid);
|
const userInfo = cache.get(req.uid);
|
||||||
|
|||||||
32
Development/server/model/api_key.js
Normal file
32
Development/server/model/api_key.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const mongoose = require('mongoose'), Schema = mongoose.Schema;
|
||||||
|
const { ApiKeyServices } = require('../helpers/constants');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiKey model — stores hashed API keys for external data-export consumers.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. POST /api/keys → generate random key, store prefix (first 8 chars) + bcrypt hash, return plain key ONCE.
|
||||||
|
* 2. Subsequent requests supply X-API-Key header → middleware does prefix lookup + bcrypt.compare.
|
||||||
|
* 3. On match, middleware sets req.uid = key.owner so all existing ownership filters work unchanged.
|
||||||
|
*
|
||||||
|
* Security notes:
|
||||||
|
* - Plain key is NEVER stored. Only the bcrypt hash is persisted.
|
||||||
|
* - Key prefix (first 8 chars) is stored in clear text for efficient DB lookup before comparing hashes.
|
||||||
|
* - lastUsedAt is updated async (fire-and-forget) to avoid adding latency to the request path.
|
||||||
|
*/
|
||||||
|
const schema = new Schema({
|
||||||
|
owner: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
|
label: { type: String, required: true, trim: true, maxlength: 100 },
|
||||||
|
prefix: { type: String, required: true, index: true, maxlength: 8 }, // first 8 chars of plain key, stored for O(1) candidate lookup
|
||||||
|
keyHash: { type: String, required: true }, // bcrypt hash of the full plain key
|
||||||
|
service: { type: String, enum: Object.values(ApiKeyServices), default: ApiKeyServices.DATA_EXPORT, index: true }, // which service/integration this key grants access to
|
||||||
|
active: { type: Boolean, default: true, index: true },
|
||||||
|
managedBy: { type: String, enum: ['owner', 'admin'], default: 'owner' },
|
||||||
|
createdAt: { type: Date, default: Date.now },
|
||||||
|
lastUsedAt: { type: Date },
|
||||||
|
requestCount: { type: Number, default: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('ApiKey', schema);
|
||||||
@ -23,7 +23,7 @@ const schema = new Schema({
|
|||||||
utmY: { type: Number, default: 0 }, // Y in meter, UTM coordinates
|
utmY: { type: Number, default: 0 }, // Y in meter, UTM coordinates
|
||||||
swath: { type: Number, default: 0 }, // Swath width in meters
|
swath: { type: Number, default: 0 }, // Swath width in meters
|
||||||
noAC: { type: Number, default: 0 }, // Aircraft Number in a fleet mission. Not use
|
noAC: { type: Number, default: 0 }, // Aircraft Number in a fleet mission. Not use
|
||||||
sprayStat: { type: Number, alias: 'spray', default: 0 }, // 0 = Spray off, 1: Spray on
|
sprayStat: { type: Number, alias: 'spray', default: 0 }, // 0 = Spray off, 1 = Spray on, 2 = Spray on (alt flag), 3 = Spray segment START marker (anchors prevUTM_X/Y for next distance/area calc; not actual application data)
|
||||||
head: { type: Number, default: 0 }, // GPS Heading in degrees
|
head: { type: Number, default: 0 }, // GPS Heading in degrees
|
||||||
stdHdop: { type: Number, default: 0 }, // Standard HDOP
|
stdHdop: { type: Number, default: 0 }, // Standard HDOP
|
||||||
satsIn: { type: Number, default: 0 }, // Satellites in view & AC position
|
satsIn: { type: Number, default: 0 }, // Satellites in view & AC position
|
||||||
|
|||||||
34
Development/server/model/export_job.js
Normal file
34
Development/server/model/export_job.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const mongoose = require('mongoose'), Schema = mongoose.Schema;
|
||||||
|
const { ExportUnits } = require('../helpers/constants');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExportJob model — tracks async CSV/GeoJSON export requests.
|
||||||
|
*
|
||||||
|
* Lifecycle: pending → processing → ready | error
|
||||||
|
* Files are written to env.TEMP_DIR and expire after EXPORT_TTL_HOURS.
|
||||||
|
* The download endpoint streams the file and then schedules deletion.
|
||||||
|
*/
|
||||||
|
const schema = new Schema({
|
||||||
|
owner: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
|
jobId: { type: Number, required: true },
|
||||||
|
format: { type: String, enum: ['csv', 'geojson'], required: true },
|
||||||
|
interval: { type: Number, default: null }, // GPS point thinning interval in seconds, null = all points
|
||||||
|
units: { type: String, enum: Object.values(ExportUnits), default: ExportUnits.METRIC }, // output measurement system
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['pending', 'processing', 'ready', 'error'],
|
||||||
|
default: 'pending',
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
filePath: { type: String }, // absolute path on disk, set when ready
|
||||||
|
errorMsg: { type: String },
|
||||||
|
createdAt: { type: Date, default: Date.now, index: true },
|
||||||
|
expiresAt: { type: Date } // TTL; file and record deleted after this time
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-expire documents from MongoDB after expiresAt (background cleanup)
|
||||||
|
schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('ExportJob', schema);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user