copy of data-export-api branch as of April 22 2026

This commit is contained in:
Devin Major 2026-04-22 15:12:27 -04:00
parent 0836fc0fbc
commit 9ea0a43ae7
110 changed files with 69150 additions and 336 deletions

19
.gitignore vendored Normal file
View 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

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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;

View File

@ -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'
},
], ],
}, },
{ {

View File

@ -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>

View File

@ -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'] }]
: [] )
] ]
} }
); );

View File

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

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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]),

View File

@ -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`, {});
}
}

View File

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

View File

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

View File

@ -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,16 +15,23 @@ 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?.filters != null) {
// Filter-submit path: all filtering is encoded in the filters param
_ops = _ops.set('filters', ops.filters);
} else {
// Legacy reload path: use individual params
_ops = _ops
.set('clientId', ops?.clientId || '')
.set('status', ops?.status || '');
if (ops?.byTime?.length === 2) { if (ops?.byTime?.length === 2) {
for (const time of ops.byTime) { for (const time of ops.byTime) {
if (time) { if (time) {
@ -31,10 +39,25 @@ export class JobService {
} }
} }
} else { } else {
_ops = _ops.append('byTime', ops?.byTime[0] || ''); _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> {

View File

@ -0,0 +1,3 @@
.ui-g-12.ui-sm-12.ui-md-12.ui-lg-10.ui-xl-10 {
width: 100% !important;
}

View File

@ -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>

View File

@ -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();

View File

@ -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>

View File

@ -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>

View File

@ -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) => {

View File

@ -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>

View File

@ -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();

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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 => {

View File

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

View File

@ -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>

View File

@ -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();
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); 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$) {

View File

@ -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],

View File

@ -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>

View File

@ -1,3 +1,8 @@
*:focus { *:focus {
outline: none; outline: none;
} }
.ui-g-12.ui-lg-10.ui-xl-8 {
min-width: 19rem;
width:100vw;
}

View File

@ -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>
<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> <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">

View File

@ -1,6 +1,9 @@
<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="card clearfix">
<div class="ui-g">
<div class="ui-g-12 ui-md-11 ui-lg-10 ui-xl-8" style="margin: auto;">
<div class="ui-g"> <div class="ui-g">
<h1 style="margin-bottom: 1em;" i18n="@@pmtHist">Payment history</h1> <h1 style="margin-bottom: 1em;" i18n="@@pmtHist">Payment history</h1>
<div class="ui-g ui-g-12"> <div class="ui-g ui-g-12">
@ -60,6 +63,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
</ng-container> </ng-container>
<ng-template #err> <ng-template #err>

View File

@ -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>

View File

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

View File

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

View File

@ -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">&nbsp;<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>

View File

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

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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
}

View 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' };
})
)
);
}

View File

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

View 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);

View File

@ -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]
}) })

View File

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

View File

@ -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>

View File

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

View File

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

View File

@ -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;">

View File

@ -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 { }

View File

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

View File

@ -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>

View File

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

View File

@ -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 { }

View File

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

View File

@ -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(

View File

@ -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: [

View File

@ -0,0 +1,3 @@
.ui-g-12.ui-md-12.ui-lg-11.ui-xl-11 {
width: 100% !important;
}

View File

@ -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;

View File

@ -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>

View File

@ -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(() => {
if (window.innerWidth <= 640) {
// Small screen: apply height from bottom-edge drag
if (e.rectangle.height) {
this.leftPStyle = {
height: `${e.rectangle.height}px`,
};
}
} else {
this.leftPStyle = { this.leftPStyle = {
position: 'absolute', position: 'absolute',
left: `${e.rectangle.left}px`, left: `${e.rectangle.left}px`,
width: `${e.rectangle.width}px`, width: `${e.rectangle.width}px`,
}; };
this.panelState.left.width = e.rectangle.width; this.panelState.left.width = e.rectangle.width;
}
}); });
} }
onLeftPToggle(e: Event) { onLeftPToggle(e: Event) {

View File

@ -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

Binary file not shown.

Binary file not shown.

View File

@ -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

View File

@ -1 +1 @@
engine-strict=true engine-strict=false

View File

@ -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 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

View File

@ -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 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

View File

@ -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"

View 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 1030 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 };

View 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 };

View 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 };

View File

@ -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;

View File

@ -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 jobsByPilot = utils.stringToBoolean(req.query['jpo']);
if (jobsByPilot) {
const pilot = await Pilot.findById(ObjectId(req.uid), '_id', { lean: true });
if (!pilot) AppError.throw(Errors.PILOT_NOT_EXIST);
filter['operator'] = pilot._id;
} }
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']) { if (req.query['byTime']) {
filter = { ...filter, ... (mongoUtil.getDateFilter(req.query['byTime'], 'createdAt')) }; Object.assign(filter, mongoUtil.getDateFilter(req.query['byTime'], 'createdAt'));
} }
const status = Number(req.query['status']); const status = Number(req.query['status']);
if (status && Object.values(JobStatus).includes(status)) { if (status && Object.values(JobStatus).includes(status)) {
filter['status'] = status; filter['status'] = status;
} }
}
// jpo (jobs by pilot) applies to both paths
const jobsByPilot = utils.stringToBoolean(req.query['jpo']);
if (jobsByPilot) {
const pilot = await Pilot.findById(ObjectId(req.uid), '_id', { lean: true });
if (!pilot) AppError.throw(Errors.PILOT_NOT_EXIST);
filter['operator'] = pilot._id;
}
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);
} }
/** /**

View 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, ~9441090, ~13091381 |
### 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 2000129999 → `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.

View 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`.

View 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."
}
}
]
}
]
}

View File

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

View 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 };

View File

@ -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,

View File

@ -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
*/ */

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View 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);

View File

@ -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

View 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