@nauticana/sail 1.1.25 → 1.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -12
- package/package.json +2 -1
- package/src/component/document/document_review_queue.html +24 -0
- package/src/component/document/document_review_queue.ts +67 -0
- package/src/component/document/document_upload.html +31 -0
- package/src/component/document/document_upload.ts +113 -0
- package/src/component/document/partner_documents.html +33 -0
- package/src/component/document/partner_documents.ts +92 -0
- package/src/component/payment/user_payment_methods.html +1 -1
- package/src/component/payment/user_payment_methods.ts +6 -6
- package/src/component/table/action_params_dialog.html +10 -0
- package/src/component/table/action_params_dialog.ts +9 -1
- package/src/index.ts +5 -0
- package/src/model/document.ts +36 -0
- package/src/service/label.service.ts +22 -0
- package/src/service/notification_inbox.service.ts +54 -15
- package/src/service/partner_document.service.ts +111 -0
- package/src/service/realtime.service.ts +5 -3
- package/src/service/rest_service.ts +11 -1
- package/src/service/rest_url.ts +7 -4
- package/src/service/user_payment_method.service.ts +8 -14
package/README.md
CHANGED
|
@@ -24,11 +24,12 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
24
24
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
25
25
|
| **Agency** | `AgencyClientsComponent`, `AgencyStatusBannerComponent`, `AgencyAcceptComponent`, `AgencyEarningsSummaryComponent`, `CommissionLedgerComponent`, `AgencyPayoutHistoryComponent`, `AgencyPayoutProfileComponent` |
|
|
26
26
|
| **Notifications** | `NotificationCenterComponent`, `NotificationInboxService` |
|
|
27
|
+
| **Documents** | `DocumentUploadComponent`, `PartnerDocumentsComponent`, `DocumentReviewQueueComponent`, `PartnerDocumentService` |
|
|
27
28
|
| **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
|
|
28
|
-
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
29
|
+
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `PartnerDocumentService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
29
30
|
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
|
|
30
31
|
| **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
|
|
31
|
-
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `TableActionParameter`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, 2FA types, etc. |
|
|
32
|
+
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `TableActionParameter`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, `PartnerDocument`/`DocumentUploadRequest`, 2FA types, etc. |
|
|
32
33
|
| **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
|
|
33
34
|
| **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinorCurrency()` |
|
|
34
35
|
|
|
@@ -269,7 +270,7 @@ this.labels.loadLabels('ride_type').subscribe(() => {
|
|
|
269
270
|
});
|
|
270
271
|
```
|
|
271
272
|
|
|
272
|
-
Concurrent loads share one application-data subscription, completed domains stay cached, and application-data failures can be retried.
|
|
273
|
+
Concurrent loads share one application-data subscription, completed domains stay cached, and application-data failures can be retried. `getLabel` falls back to the raw key; `requireLabel` throws instead, for values that must not render as a raw code. `options(domain)` returns every `{code, caption}` of a loaded domain, sorted by caption, for picklists.
|
|
273
274
|
|
|
274
275
|
## Status-filtered API errors
|
|
275
276
|
|
|
@@ -400,7 +401,7 @@ Every keel login response (keel v1.2.57+) carries `refreshToken` beside `token`;
|
|
|
400
401
|
|
|
401
402
|
### Realtime
|
|
402
403
|
|
|
403
|
-
`RealtimeService` (root-provided) is the client for keel's `realtime.Hub`. `connect()` opens `RestURL.realtimeURL` (`/public/ws`) on the configured backend host with the stored JWT, reconnects with backoff, and re-subscribes every channel after a reconnect. `channel<T>(name)` returns the payloads keel broadcasts on that channel; `userFrames<T>()` returns payloads addressed to the user; `send(frame)` forwards an application frame to the hub's `OnMessage`. A subscription keel's `CanSubscribe` rejects is logged and exposed on `lastError`. Channel names and payload shapes are app-owned.
|
|
404
|
+
`RealtimeService` (root-provided) is the client for keel's `realtime.Hub`. `connect()` opens `RestURL.realtimeURL` (`/public/ws`) on the configured backend host with the stored JWT, reconnects with backoff, and re-subscribes every channel after a reconnect. `channel<T>(name)` returns the payloads keel broadcasts on that channel; `userFrames<T>()` returns payloads addressed to the user, including keel's `{op: "notification"}` frames; `send(frame)` forwards an application frame to the hub's `OnMessage`. A subscription keel's `CanSubscribe` rejects is logged and exposed on `lastError`. Channel names and payload shapes are app-owned.
|
|
404
405
|
|
|
405
406
|
## Billing
|
|
406
407
|
|
|
@@ -570,7 +571,7 @@ A `TableAction` with `kind: 'R'` (redirect — the `constant_value` code) makes
|
|
|
570
571
|
|
|
571
572
|
### Action parameters (v1.1.25)
|
|
572
573
|
|
|
573
|
-
An action with `parameters` (keel v1.2.75 `table_action_parameter`) opens a dialog before posting: one field per parameter, typed by `dataType` (`boolean` checkbox, numeric, `date`, `timestamp`, otherwise text), or a dropdown when `lookupTable` names a table in the client cache (a `foreign_key_lookup` dropdown table); a numeric `dataType` still posts the chosen key as a number. The dialog shows `confirmMessage` in place of the native prompt and posts `{...primaryKeyValues(record), ...values}`; empty optional values are left out. An action without parameters behaves as before.
|
|
574
|
+
An action with `parameters` (keel v1.2.75 `table_action_parameter`) opens a dialog before posting: one field per parameter, typed by `dataType` (`boolean` checkbox, numeric, `date`, `timestamp`, `file`, otherwise text), or a dropdown when `lookupTable` names a table in the client cache (a `foreign_key_lookup` dropdown table); a numeric `dataType` still posts the chosen key as a number. The dialog shows `confirmMessage` in place of the native prompt and posts `{...primaryKeyValues(record), ...values}`; empty optional values are left out. A `file` parameter turns the post into `multipart/form-data`, so a table-level `upload` action whose parameters are `file`, `document_type` (lookup `document_type`), `title`, … posts straight to keel's `DocumentHandler.Upload`. An action without parameters behaves as before.
|
|
574
575
|
|
|
575
576
|
### Status chip (v1.0.1)
|
|
576
577
|
|
|
@@ -1080,8 +1081,10 @@ readonly unread = inject(NotificationInboxService).unreadCount; // Signal<numb
|
|
|
1080
1081
|
| `load(limit)` / `loadMore(limit)` | Default 20; keel caps a page at 100 and the client clamps to match. |
|
|
1081
1082
|
|
|
1082
1083
|
Message ids are server-side `BIGINT`s serialized as strings. Both mark-read routes
|
|
1083
|
-
return `204`, so the service updates its cache locally.
|
|
1084
|
-
|
|
1084
|
+
return `204`, so the service updates its cache locally. After the first successful `load()`, each
|
|
1085
|
+
`{op: "notification"}` frame from keel's `realtime.UserDispatcher` (v1.2.79+) on a
|
|
1086
|
+
connected `RealtimeService` re-fetches the newest page into the cache. Call `reset()`
|
|
1087
|
+
on logout.
|
|
1085
1088
|
|
|
1086
1089
|
```html
|
|
1087
1090
|
<sail-notification-center
|
|
@@ -1105,6 +1108,31 @@ Ships no CSS — style
|
|
|
1105
1108
|
`.notification-item`, `.notification-item--unread` and
|
|
1106
1109
|
`.notification-item__{select,static,title,body,time,read}`.
|
|
1107
1110
|
|
|
1111
|
+
## Partner documents (v1.1.26)
|
|
1112
|
+
|
|
1113
|
+
`PartnerDocumentService` covers generic `partner_document` reads plus keel's
|
|
1114
|
+
`handler.DocumentHandler` (keel **v1.2.79+**): `list(userId?)`, `pending()`,
|
|
1115
|
+
`upload(file, request)` (progress events), `previewUrl(id)` and `review(id, approve,
|
|
1116
|
+
notes)`. The defaults assume the handler is mounted at
|
|
1117
|
+
`/api/v1/partner_document/{upload,preview,review}`; override the three
|
|
1118
|
+
`partnerDocument*URL` values otherwise. Authorization stays in keel's hooks.
|
|
1119
|
+
|
|
1120
|
+
```html
|
|
1121
|
+
<sail-document-upload documentType="driver_license" [maxBytes]="10485760"
|
|
1122
|
+
[canUpload]="attested()" (uploaded)="onUploaded($event)" (failed)="onFailed($event)">
|
|
1123
|
+
<!-- app-specific attestations -->
|
|
1124
|
+
</sail-document-upload>
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
Images preview inline, PDFs open in a new tab. `(failed)` hands over the raw error;
|
|
1128
|
+
branch on `errorCode(err)` for keel's `document_*` codes. Status captions come from
|
|
1129
|
+
the `partner_document_status` domain through `LabelService`.
|
|
1130
|
+
|
|
1131
|
+
`<sail-partner-documents>` shows the latest version per type and offers replacement
|
|
1132
|
+
uploads for rejected or expiring documents. `<sail-document-review-queue>` lists
|
|
1133
|
+
pending rows with an inline preview and approve/reject controls. Both rely on the
|
|
1134
|
+
app's generic `partner_document` read grants and the handler authorization hooks.
|
|
1135
|
+
|
|
1108
1136
|
## Grouped review actions and diffs (v1.1.19)
|
|
1109
1137
|
|
|
1110
1138
|
`GroupedBulkActionComponent` (`sail-grouped-bulk-action`) groups eligible rows
|
|
@@ -1481,8 +1509,8 @@ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.
|
|
|
1481
1509
|
| `@nauticana/sail` | `PayoutService` | keel/payout API client — onboarding, reuse, status, bank-version replacement, beneficiary registration |
|
|
1482
1510
|
| `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker, hosted-KYC handoff, and asynchronous-confirmation output |
|
|
1483
1511
|
| `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form (country / currency / tax ID / billing address / agreement) |
|
|
1484
|
-
| `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list /
|
|
1485
|
-
| `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default +
|
|
1512
|
+
| `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / remove / set-default API client |
|
|
1513
|
+
| `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + remove |
|
|
1486
1514
|
| `@nauticana/sail` | `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `UserPaymentMethod`, `DEFAULT_COUNTRY_PROFILES` | Model types / defaults |
|
|
1487
1515
|
|
|
1488
1516
|
### 2. New keel endpoints
|
|
@@ -1497,10 +1525,11 @@ Make sure your keel deployment exposes these — they're all under `/api/v1/`:
|
|
|
1497
1525
|
| `/api/v1/payout/status` | POST | `{ complete: true }` once the active partner's row has a `providerAccountId` |
|
|
1498
1526
|
| `/api/v1/payout/bank/replace` | POST | Supersede the active bank-info version and insert its replacement atomically; the app must wire the handler's `SealTaxID` hook |
|
|
1499
1527
|
| `/api/v1/payout/beneficiary/register` | POST | Create and link a provider beneficiary from provider-collected details |
|
|
1500
|
-
| `/api/v1/
|
|
1528
|
+
| `/api/v1/user_payment_method/set_default` | POST | `{id}` — sets one row as default, clears the rest |
|
|
1529
|
+
| `/api/v1/user_payment_method/remove` | POST | `{id}` — detaches the method at the provider, then deletes the row (keel v1.2.79) |
|
|
1501
1530
|
| `/api/v1/{table}/{action_name}` | POST | Per-table custom actions resolved from `basis.table_action` |
|
|
1502
1531
|
|
|
1503
|
-
`list`
|
|
1532
|
+
`list` for `user_payment_method` is keel's generic REST CRUD, auto-scoped to the caller. keel grants no generic DELETE on the table, so removal goes through `remove`.
|
|
1504
1533
|
|
|
1505
1534
|
### 3. Payout onboarding wiring
|
|
1506
1535
|
|
|
@@ -1539,7 +1568,7 @@ For apps that operate on a single keel partner, the reuse picker stays empty and
|
|
|
1539
1568
|
[title]="'Payment Methods'"
|
|
1540
1569
|
(addClicked)="goToSetupIntent()"
|
|
1541
1570
|
(defaultChanged)="onDefaultChanged($event)"
|
|
1542
|
-
(
|
|
1571
|
+
(removed)="onRemoved($event)">
|
|
1543
1572
|
</sail-user-payment-methods>
|
|
1544
1573
|
```
|
|
1545
1574
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.26",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@angular/core": "^21.0.0",
|
|
53
53
|
"@angular/forms": "^21.0.0",
|
|
54
54
|
"@angular/material": "^21.0.0",
|
|
55
|
+
"@angular/platform-browser": "^21.0.0",
|
|
55
56
|
"@angular/router": "^21.0.0",
|
|
56
57
|
"class-validator": "^0.15.1",
|
|
57
58
|
"rxjs": "~7.8.0"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<section class="document-review-queue" [attr.aria-busy]="loading()">
|
|
2
|
+
@if (errorMessage()) { <p class="document-review-queue__error" role="alert">{{ errorMessage() }}</p> }
|
|
3
|
+
@if (!loading() && !documents().length) {
|
|
4
|
+
<p class="document-review-queue__empty">No documents are awaiting review.</p>
|
|
5
|
+
}
|
|
6
|
+
@for (doc of documents(); track doc.Id) {
|
|
7
|
+
<article class="document-review">
|
|
8
|
+
<h3>{{ doc.Title || doc.DocumentType }}</h3>
|
|
9
|
+
<p>{{ doc.FileName }} · version {{ doc.VersionNo }}</p>
|
|
10
|
+
<button matButton type="button" (click)="showPreview(doc)">Preview</button>
|
|
11
|
+
@if (preview()?.id === doc.Id) {
|
|
12
|
+
<iframe class="document-review__preview" [src]="preview()!.url" [title]="'Preview of ' + (doc.Title || doc.FileName)"></iframe>
|
|
13
|
+
}
|
|
14
|
+
<mat-form-field appearance="outline">
|
|
15
|
+
<mat-label>Review notes</mat-label>
|
|
16
|
+
<textarea matInput [value]="notes()[doc.Id]" (input)="setNotes(doc.Id, $event)"></textarea>
|
|
17
|
+
</mat-form-field>
|
|
18
|
+
<div class="document-review__actions">
|
|
19
|
+
<button matButton="filled" type="button" [disabled]="reviewing() === doc.Id" (click)="review(doc, true)">Approve</button>
|
|
20
|
+
<button matButton type="button" [disabled]="reviewing() === doc.Id" (click)="review(doc, false)">Reject</button>
|
|
21
|
+
</div>
|
|
22
|
+
</article>
|
|
23
|
+
}
|
|
24
|
+
</section>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, SecurityContext, ViewEncapsulation, inject, signal } from '@angular/core';
|
|
2
|
+
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
5
|
+
import { MatInputModule } from '@angular/material/input';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { PartnerDocument } from '../../model/document';
|
|
8
|
+
import { PartnerDocumentService } from '../../service/partner_document.service';
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
selector: 'sail-document-review-queue',
|
|
12
|
+
templateUrl: './document_review_queue.html',
|
|
13
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
|
+
encapsulation: ViewEncapsulation.None,
|
|
15
|
+
imports: [MatButtonModule, MatFormFieldModule, MatInputModule],
|
|
16
|
+
})
|
|
17
|
+
export class DocumentReviewQueueComponent extends BaseAsync implements OnInit {
|
|
18
|
+
private readonly service = inject(PartnerDocumentService);
|
|
19
|
+
private readonly sanitizer = inject(DomSanitizer);
|
|
20
|
+
|
|
21
|
+
readonly documents = signal<PartnerDocument[]>([]);
|
|
22
|
+
readonly preview = signal<{ id: number; url: SafeResourceUrl } | null>(null);
|
|
23
|
+
readonly reviewing = signal<number | null>(null);
|
|
24
|
+
readonly notes = signal<Record<number, string>>({});
|
|
25
|
+
|
|
26
|
+
ngOnInit(): void {
|
|
27
|
+
this.reload();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
reload(): void {
|
|
31
|
+
this.run(this.service.pending(), (rows) => this.documents.set(rows), 'Could not load pending documents.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
showPreview(doc: PartnerDocument): void {
|
|
35
|
+
this.service.previewUrl(doc.Id).subscribe({
|
|
36
|
+
next: (url) => {
|
|
37
|
+
const safe = this.sanitizer.sanitize(SecurityContext.URL, url);
|
|
38
|
+
if (!safe) {
|
|
39
|
+
this.errorMessage.set('The document preview URL is invalid.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.preview.set({ id: doc.Id, url: this.sanitizer.bypassSecurityTrustResourceUrl(safe) });
|
|
43
|
+
},
|
|
44
|
+
error: (err) => this.setError(err, 'Could not preview the document.'),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setNotes(id: number, event: Event): void {
|
|
49
|
+
const value = (event.target as HTMLTextAreaElement).value;
|
|
50
|
+
this.notes.update((notes) => ({ ...notes, [id]: value }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
review(doc: PartnerDocument, approve: boolean): void {
|
|
54
|
+
this.reviewing.set(doc.Id);
|
|
55
|
+
this.service.review(doc.Id, approve, this.notes()[doc.Id] ?? '').subscribe({
|
|
56
|
+
next: () => {
|
|
57
|
+
this.documents.update((rows) => rows.filter((row) => row.Id !== doc.Id));
|
|
58
|
+
if (this.preview()?.id === doc.Id) this.preview.set(null);
|
|
59
|
+
this.reviewing.set(null);
|
|
60
|
+
},
|
|
61
|
+
error: (err) => {
|
|
62
|
+
this.reviewing.set(null);
|
|
63
|
+
this.setError(err, 'Could not review the document.');
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<div class="document-upload">
|
|
2
|
+
@if (!file()) {
|
|
3
|
+
<button matButton="outlined" type="button" class="document-upload__pick" (click)="picker.click()">
|
|
4
|
+
<mat-icon>upload_file</mat-icon> {{ label() }}
|
|
5
|
+
</button>
|
|
6
|
+
} @else {
|
|
7
|
+
<div class="document-upload__preview">
|
|
8
|
+
@if (imageUrl(); as src) {
|
|
9
|
+
<img [src]="src" [alt]="file()!.name" class="document-upload__image">
|
|
10
|
+
} @else if (pdfUrl(); as src) {
|
|
11
|
+
<a [href]="src" target="_blank" rel="noopener" class="document-upload__pdf">Open preview</a>
|
|
12
|
+
}
|
|
13
|
+
<span class="document-upload__name">{{ file()!.name }}</span>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<ng-content />
|
|
17
|
+
|
|
18
|
+
<div class="document-upload__actions">
|
|
19
|
+
<button matButton="filled" type="button" (click)="upload()" [disabled]="!canUpload() || uploading()">Upload</button>
|
|
20
|
+
<button matButton="outlined" type="button" (click)="picker.click()" [disabled]="uploading()">Choose another</button>
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
<input #picker type="file" hidden [accept]="accept()" (change)="pick($event)" [attr.aria-label]="label()">
|
|
24
|
+
|
|
25
|
+
@if (progress() !== null) {
|
|
26
|
+
<mat-progress-bar mode="determinate" [value]="progress()" aria-label="Upload progress" />
|
|
27
|
+
}
|
|
28
|
+
@if (errorMessage()) {
|
|
29
|
+
<p class="document-upload__error" role="alert">{{ errorMessage() }}</p>
|
|
30
|
+
}
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, DestroyRef, ViewEncapsulation, computed, inject, input, output, signal } from '@angular/core';
|
|
2
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
3
|
+
import { HttpEventType } from '@angular/common/http';
|
|
4
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
5
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
6
|
+
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
7
|
+
import { PartnerDocumentService } from '../../service/partner_document.service';
|
|
8
|
+
import { PartnerDocument } from '../../model/document';
|
|
9
|
+
import { errorDetail } from '../../util/errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Picks, previews and uploads one keel partner document. App-specific
|
|
13
|
+
* attestations are projected content; bind `[canUpload]` to their state.
|
|
14
|
+
* Ships no CSS — style the `.document-upload*` classes globally.
|
|
15
|
+
*/
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'sail-document-upload',
|
|
18
|
+
templateUrl: './document_upload.html',
|
|
19
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
20
|
+
encapsulation: ViewEncapsulation.None,
|
|
21
|
+
imports: [MatButtonModule, MatIconModule, MatProgressBarModule],
|
|
22
|
+
})
|
|
23
|
+
export class DocumentUploadComponent {
|
|
24
|
+
private readonly documents = inject(PartnerDocumentService);
|
|
25
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
26
|
+
|
|
27
|
+
readonly documentType = input.required<string>();
|
|
28
|
+
readonly title = input('');
|
|
29
|
+
readonly documentNumber = input('');
|
|
30
|
+
/** YYYY-MM-DD. */
|
|
31
|
+
readonly expiresOn = input('');
|
|
32
|
+
readonly userId = input<number | undefined>(undefined);
|
|
33
|
+
readonly accept = input('image/*,application/pdf');
|
|
34
|
+
/** Client-side size cap; 0 leaves the limit to keel's document type. */
|
|
35
|
+
readonly maxBytes = input(0);
|
|
36
|
+
readonly label = input('Choose file');
|
|
37
|
+
readonly canUpload = input(true);
|
|
38
|
+
|
|
39
|
+
readonly uploaded = output<PartnerDocument>();
|
|
40
|
+
/** The raw error; branch on `errorCode(err)` (keel's `document_*` codes). */
|
|
41
|
+
readonly failed = output<unknown>();
|
|
42
|
+
|
|
43
|
+
readonly file = signal<File | null>(null);
|
|
44
|
+
readonly progress = signal<number | null>(null);
|
|
45
|
+
readonly errorMessage = signal('');
|
|
46
|
+
private readonly objectUrl = signal<string | null>(null);
|
|
47
|
+
|
|
48
|
+
readonly isImage = computed(() => this.file()?.type.startsWith('image/') ?? false);
|
|
49
|
+
readonly imageUrl = computed(() => (this.isImage() ? this.objectUrl() : null));
|
|
50
|
+
readonly pdfUrl = computed(() => (this.file()?.type === 'application/pdf' ? this.objectUrl() : null));
|
|
51
|
+
readonly uploading = computed(() => this.progress() !== null);
|
|
52
|
+
|
|
53
|
+
constructor() {
|
|
54
|
+
this.destroyRef.onDestroy(() => this.revokePreview());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pick(event: Event): void {
|
|
58
|
+
const input = event.target as HTMLInputElement;
|
|
59
|
+
const file = input.files?.[0] ?? null;
|
|
60
|
+
input.value = '';
|
|
61
|
+
this.errorMessage.set('');
|
|
62
|
+
this.revokePreview();
|
|
63
|
+
this.file.set(null);
|
|
64
|
+
if (!file) return;
|
|
65
|
+
const max = this.maxBytes();
|
|
66
|
+
if (max > 0 && file.size > max) {
|
|
67
|
+
this.errorMessage.set(`The file exceeds the ${(max / 1048576).toFixed(1)} MB limit.`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.file.set(file);
|
|
71
|
+
this.objectUrl.set(URL.createObjectURL(file));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
upload(): void {
|
|
75
|
+
const file = this.file();
|
|
76
|
+
if (!file || !this.canUpload() || this.uploading()) return;
|
|
77
|
+
this.errorMessage.set('');
|
|
78
|
+
this.progress.set(0);
|
|
79
|
+
this.documents.upload(file, {
|
|
80
|
+
documentType: this.documentType(),
|
|
81
|
+
title: this.title() || undefined,
|
|
82
|
+
documentNumber: this.documentNumber() || undefined,
|
|
83
|
+
expiresOn: this.expiresOn() || undefined,
|
|
84
|
+
userId: this.userId(),
|
|
85
|
+
}).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
|
86
|
+
next: (event) => {
|
|
87
|
+
if (event.type === HttpEventType.UploadProgress && event.total) {
|
|
88
|
+
this.progress.set(Math.round((100 * event.loaded) / event.total));
|
|
89
|
+
} else if (event.type === HttpEventType.Response && event.body) {
|
|
90
|
+
this.progress.set(null);
|
|
91
|
+
this.clear();
|
|
92
|
+
this.uploaded.emit(event.body);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
error: (err) => {
|
|
96
|
+
this.progress.set(null);
|
|
97
|
+
this.errorMessage.set(errorDetail(err, 'Upload failed.'));
|
|
98
|
+
this.failed.emit(err);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
clear(): void {
|
|
104
|
+
this.revokePreview();
|
|
105
|
+
this.file.set(null);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private revokePreview(): void {
|
|
109
|
+
const url = this.objectUrl();
|
|
110
|
+
if (url) URL.revokeObjectURL(url);
|
|
111
|
+
this.objectUrl.set(null);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<section class="partner-documents" [attr.aria-busy]="loading()">
|
|
2
|
+
@if (heading()) { <h2 class="partner-documents__title">{{ heading() }}</h2> }
|
|
3
|
+
@if (errorMessage()) { <p class="partner-documents__error" role="alert">{{ errorMessage() }}</p> }
|
|
4
|
+
|
|
5
|
+
@if (!loading() && !latest().length) {
|
|
6
|
+
<p class="partner-documents__empty">{{ emptyMessage() }}</p>
|
|
7
|
+
} @else {
|
|
8
|
+
<ul class="partner-documents__list">
|
|
9
|
+
@for (doc of latest(); track doc.Id) {
|
|
10
|
+
<li class="partner-document" [class.partner-document--rejected]="doc.Status === 'N'">
|
|
11
|
+
<div class="partner-document__details">
|
|
12
|
+
<strong>{{ doc.Title || doc.DocumentType }}</strong>
|
|
13
|
+
<span>{{ status(doc.Status) }} · version {{ doc.VersionNo }}</span>
|
|
14
|
+
@if (doc.ExpiresOn) { <span>Expires {{ doc.ExpiresOn | date }}</span> }
|
|
15
|
+
@if (doc.ReviewerNotes) { <span>{{ doc.ReviewerNotes }}</span> }
|
|
16
|
+
</div>
|
|
17
|
+
<div class="partner-document__actions">
|
|
18
|
+
<button matButton type="button" (click)="preview(doc)">
|
|
19
|
+
<mat-icon>visibility</mat-icon> Preview
|
|
20
|
+
</button>
|
|
21
|
+
@if (canReupload(doc)) {
|
|
22
|
+
<button matButton type="button" (click)="reuploading.set(doc)">Upload replacement</button>
|
|
23
|
+
}
|
|
24
|
+
</div>
|
|
25
|
+
@if (reuploading()?.Id === doc.Id) {
|
|
26
|
+
<sail-document-upload [documentType]="doc.DocumentType" [title]="doc.Title"
|
|
27
|
+
[documentNumber]="doc.DocumentNumber ?? ''" [userId]="doc.UserId ?? undefined" (uploaded)="uploaded()" />
|
|
28
|
+
}
|
|
29
|
+
</li>
|
|
30
|
+
}
|
|
31
|
+
</ul>
|
|
32
|
+
}
|
|
33
|
+
</section>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, computed, inject, input, signal } from '@angular/core';
|
|
2
|
+
import { DatePipe } from '@angular/common';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
5
|
+
import { forkJoin } from 'rxjs';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { PartnerDocument } from '../../model/document';
|
|
8
|
+
import { LabelService } from '../../service/label.service';
|
|
9
|
+
import { PartnerDocumentService } from '../../service/partner_document.service';
|
|
10
|
+
import { DocumentUploadComponent } from './document_upload';
|
|
11
|
+
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'sail-partner-documents',
|
|
14
|
+
templateUrl: './partner_documents.html',
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
encapsulation: ViewEncapsulation.None,
|
|
17
|
+
imports: [DatePipe, MatButtonModule, MatIconModule, DocumentUploadComponent],
|
|
18
|
+
})
|
|
19
|
+
export class PartnerDocumentsComponent extends BaseAsync implements OnInit {
|
|
20
|
+
private readonly service = inject(PartnerDocumentService);
|
|
21
|
+
private readonly labels = inject(LabelService);
|
|
22
|
+
|
|
23
|
+
readonly userId = input<number | undefined>(undefined);
|
|
24
|
+
readonly heading = input('Documents');
|
|
25
|
+
readonly emptyMessage = input('No documents uploaded.');
|
|
26
|
+
readonly expiringWithinDays = input(30);
|
|
27
|
+
|
|
28
|
+
readonly documents = signal<PartnerDocument[]>([]);
|
|
29
|
+
readonly reuploading = signal<PartnerDocument | null>(null);
|
|
30
|
+
readonly statusesReady = signal(false);
|
|
31
|
+
readonly latest = computed(() => latestByType(this.documents()));
|
|
32
|
+
|
|
33
|
+
ngOnInit(): void {
|
|
34
|
+
this.run(forkJoin({
|
|
35
|
+
labels: this.labels.loadLabels('partner_document_status'),
|
|
36
|
+
documents: this.service.list(this.userId()),
|
|
37
|
+
}), ({ documents }) => {
|
|
38
|
+
this.statusesReady.set(true);
|
|
39
|
+
this.documents.set(documents);
|
|
40
|
+
}, 'Could not load documents.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
reload(): void {
|
|
44
|
+
this.run(this.service.list(this.userId()), (rows) => this.documents.set(rows), 'Could not load documents.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
status(status: string): string {
|
|
48
|
+
return this.statusesReady() ? this.labels.requireLabel('partner_document_status', status) : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
canReupload(doc: PartnerDocument): boolean {
|
|
52
|
+
return doc.Status === 'N' || isExpiring(doc.ExpiresOn, this.expiringWithinDays());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
preview(doc: PartnerDocument): void {
|
|
56
|
+
// Opened synchronously so the popup blocker allows it; 'noopener' would return null.
|
|
57
|
+
const tab = window.open('', '_blank');
|
|
58
|
+
if (tab) tab.opener = null;
|
|
59
|
+
this.service.previewUrl(doc.Id).subscribe({
|
|
60
|
+
next: (url) => {
|
|
61
|
+
if (tab) tab.location.href = url;
|
|
62
|
+
else window.open(url, '_blank', 'noopener');
|
|
63
|
+
},
|
|
64
|
+
error: (err) => {
|
|
65
|
+
tab?.close();
|
|
66
|
+
this.setError(err, 'Could not open the document.');
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
uploaded(): void {
|
|
72
|
+
this.reuploading.set(null);
|
|
73
|
+
this.reload();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function latestByType(rows: PartnerDocument[]): PartnerDocument[] {
|
|
78
|
+
const latest = new Map<string, PartnerDocument>();
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const key = `${row.UserId ?? ''}:${row.DocumentType}`;
|
|
81
|
+
const current = latest.get(key);
|
|
82
|
+
if (!current || row.VersionNo > current.VersionNo) latest.set(key, row);
|
|
83
|
+
}
|
|
84
|
+
return [...latest.values()].sort((a, b) => a.DocumentType.localeCompare(b.DocumentType));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isExpiring(value: string | null, withinDays: number): boolean {
|
|
88
|
+
if (!value || withinDays < 0) return false;
|
|
89
|
+
const expires = new Date(`${value.slice(0, 10)}T00:00:00`);
|
|
90
|
+
if (Number.isNaN(expires.getTime())) return false;
|
|
91
|
+
return expires.getTime() <= Date.now() + withinDays * 86400000;
|
|
92
|
+
}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
@if (!m.IsDefault) {
|
|
32
32
|
<button matButton (click)="setDefault(m)">Set default</button>
|
|
33
33
|
}
|
|
34
|
-
<button matIconButton (click)="remove(m)" aria-label="
|
|
34
|
+
<button matIconButton (click)="remove(m)" aria-label="Remove payment method">
|
|
35
35
|
<mat-icon>delete</mat-icon>
|
|
36
36
|
</button>
|
|
37
37
|
</div>
|
|
@@ -10,7 +10,7 @@ import { UserPaymentMethod } from '../../model/appdata';
|
|
|
10
10
|
* End-user saved payment methods.
|
|
11
11
|
*
|
|
12
12
|
* Drop-in screen that lists the caller's cards/wallets, with set-default
|
|
13
|
-
* +
|
|
13
|
+
* + remove actions. Inherits loading/error state from BaseAsync.
|
|
14
14
|
*
|
|
15
15
|
* Customise the heading and "add" CTA via inputs; emit (addClicked) so
|
|
16
16
|
* the consumer routes to its own SetupIntent flow: BillingService.createSetupIntent()
|
|
@@ -37,8 +37,8 @@ export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
|
|
|
37
37
|
readonly addClicked = output<void>();
|
|
38
38
|
/** Emitted after a successful set-default flip. */
|
|
39
39
|
readonly defaultChanged = output<UserPaymentMethod>();
|
|
40
|
-
/** Emitted after a
|
|
41
|
-
readonly
|
|
40
|
+
/** Emitted after a method is detached and removed. */
|
|
41
|
+
readonly removed = output<UserPaymentMethod>();
|
|
42
42
|
|
|
43
43
|
private readonly service = inject(UserPaymentMethodService);
|
|
44
44
|
|
|
@@ -78,12 +78,12 @@ export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
|
|
|
78
78
|
|
|
79
79
|
remove(m: UserPaymentMethod): void {
|
|
80
80
|
this.run(
|
|
81
|
-
this.service.
|
|
81
|
+
this.service.remove(m.Id),
|
|
82
82
|
() => {
|
|
83
83
|
this.methods.update((list) => list.filter((row) => row.Id !== m.Id));
|
|
84
|
-
this.
|
|
84
|
+
this.removed.emit(m);
|
|
85
85
|
},
|
|
86
|
-
'Failed to
|
|
86
|
+
'Failed to remove.',
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
@case ('checkbox') {
|
|
10
10
|
<mat-checkbox [formControlName]="f.param.name">{{ f.param.caption }}</mat-checkbox>
|
|
11
11
|
}
|
|
12
|
+
@case ('file') {
|
|
13
|
+
<div class="action-param-file">
|
|
14
|
+
<label [for]="'action-param-' + f.param.name">{{ f.param.caption }}@if (f.param.required) { * }</label>
|
|
15
|
+
<input type="file" [id]="'action-param-' + f.param.name" [required]="f.param.required"
|
|
16
|
+
(change)="pickFile(f.param.name, $event)">
|
|
17
|
+
@if (form.controls[f.param.name].touched && form.controls[f.param.name].invalid) {
|
|
18
|
+
<p class="action-param-error" role="alert">{{ f.param.caption }} is required</p>
|
|
19
|
+
}
|
|
20
|
+
</div>
|
|
21
|
+
}
|
|
12
22
|
@case ('select') {
|
|
13
23
|
<mat-form-field appearance="outline">
|
|
14
24
|
<mat-label>{{ f.param.caption }}</mat-label>
|
|
@@ -10,7 +10,7 @@ import { TableAction, TableActionParameter } from "../../model/appdata";
|
|
|
10
10
|
import { ConstantValue } from "../../model/common";
|
|
11
11
|
import { BaseAuthService } from "../../service/auth.service";
|
|
12
12
|
|
|
13
|
-
type ParamKind = 'checkbox' | 'number' | 'date' | 'datetime-local' | 'text';
|
|
13
|
+
type ParamKind = 'checkbox' | 'number' | 'date' | 'datetime-local' | 'file' | 'text';
|
|
14
14
|
|
|
15
15
|
interface ParamField {
|
|
16
16
|
param: TableActionParameter;
|
|
@@ -65,6 +65,12 @@ export class ActionParamsDialog {
|
|
|
65
65
|
}
|
|
66
66
|
this.dialogRef.close(values);
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
pickFile(name: string, event: Event): void {
|
|
70
|
+
const control = this.form.controls[name];
|
|
71
|
+
control.setValue((event.target as HTMLInputElement).files?.[0] ?? '');
|
|
72
|
+
control.markAsTouched();
|
|
73
|
+
}
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
function kindOf(dataType: string): ParamKind {
|
|
@@ -83,6 +89,8 @@ function kindOf(dataType: string): ParamKind {
|
|
|
83
89
|
case 'datetime':
|
|
84
90
|
case 'timestamp':
|
|
85
91
|
return 'datetime-local';
|
|
92
|
+
case 'file':
|
|
93
|
+
return 'file';
|
|
86
94
|
default:
|
|
87
95
|
return 'text';
|
|
88
96
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from './model/common';
|
|
|
5
5
|
export * from './model/dashboard';
|
|
6
6
|
export * from './model/oauth_connection';
|
|
7
7
|
export * from './model/notification';
|
|
8
|
+
export * from './model/document';
|
|
8
9
|
export * from './model/agency';
|
|
9
10
|
export * from './model/decorator';
|
|
10
11
|
export * from './component/abstract/base_table';
|
|
@@ -70,6 +71,9 @@ export * from './component/agency/agency_payout_history';
|
|
|
70
71
|
export * from './component/agency/agency_payout_profile';
|
|
71
72
|
export * from './component/payment/user_payment_methods';
|
|
72
73
|
export * from './component/payment/sca_confirm';
|
|
74
|
+
export * from './component/document/document_upload';
|
|
75
|
+
export * from './component/document/partner_documents';
|
|
76
|
+
export * from './component/document/document_review_queue';
|
|
73
77
|
export * from './service/auth.service';
|
|
74
78
|
export * from './service/sca_confirmer';
|
|
75
79
|
export * from './service/domain_verifier';
|
|
@@ -79,6 +83,7 @@ export * from './service/agency.service';
|
|
|
79
83
|
export * from './service/oauth_connection.service';
|
|
80
84
|
export * from './service/notification_inbox.service';
|
|
81
85
|
export * from './service/user_payment_method.service';
|
|
86
|
+
export * from './service/partner_document.service';
|
|
82
87
|
export * from './service/script_loader';
|
|
83
88
|
export * from './service/auth.interceptor';
|
|
84
89
|
export * from './service/realtime.service';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** One `partner_document` row in Sail's generic REST field shape. */
|
|
2
|
+
export interface PartnerDocument {
|
|
3
|
+
Id: number;
|
|
4
|
+
ContrepId: string;
|
|
5
|
+
DocKey: string;
|
|
6
|
+
PartnerId: number;
|
|
7
|
+
/** null means the partner itself. */
|
|
8
|
+
UserId: number | null;
|
|
9
|
+
DocumentType: string;
|
|
10
|
+
Title: string;
|
|
11
|
+
FileName: string;
|
|
12
|
+
VersionNo: number;
|
|
13
|
+
DocumentNumber: string | null;
|
|
14
|
+
ExpiresOn: string | null;
|
|
15
|
+
OriginIp: string | null;
|
|
16
|
+
UploadedBy: number | null;
|
|
17
|
+
UploadedAt: string;
|
|
18
|
+
/** `partner_document_status`: P pending, Y approved, N rejected, X superseded, R retired. */
|
|
19
|
+
Status: string;
|
|
20
|
+
ReviewerId: number | null;
|
|
21
|
+
ReviewedAt: string | null;
|
|
22
|
+
ReviewerNotes: string | null;
|
|
23
|
+
SupersededAt: string | null;
|
|
24
|
+
RetiredAt: string | null;
|
|
25
|
+
PurgedAt: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DocumentUploadRequest {
|
|
29
|
+
documentType: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
documentNumber?: string;
|
|
32
|
+
/** YYYY-MM-DD. */
|
|
33
|
+
expiresOn?: string;
|
|
34
|
+
/** The member the document is about; omitted = the partner. */
|
|
35
|
+
userId?: number;
|
|
36
|
+
}
|
|
@@ -2,6 +2,11 @@ import { inject, Injectable } from "@angular/core";
|
|
|
2
2
|
import { Observable, map, of, shareReplay, take, tap } from "rxjs";
|
|
3
3
|
import { BaseAuthService } from "./auth.service";
|
|
4
4
|
|
|
5
|
+
export interface LabelOption {
|
|
6
|
+
code: string;
|
|
7
|
+
caption: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
@Injectable({ providedIn: 'root' })
|
|
6
11
|
export class LabelService {
|
|
7
12
|
private readonly auth = inject(BaseAuthService);
|
|
@@ -13,6 +18,23 @@ export class LabelService {
|
|
|
13
18
|
return this.labels.get(constantId)?.get(key) ?? key;
|
|
14
19
|
}
|
|
15
20
|
|
|
21
|
+
/** Caption of a backend-sourced value; throws when the domain is not loaded or lacks the key. */
|
|
22
|
+
requireLabel(constantId: string, key: string): string {
|
|
23
|
+
const domain = this.labels.get(constantId);
|
|
24
|
+
if (!domain) throw new Error(`sail: constant domain "${constantId}" is not loaded.`);
|
|
25
|
+
const caption = domain.get(key);
|
|
26
|
+
if (caption === undefined) throw new Error(`sail: constant domain "${constantId}" has no value "${key}".`);
|
|
27
|
+
return caption;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every value of a loaded domain, by caption (keel's ConstantCache carries no display order). */
|
|
31
|
+
options(constantId: string): LabelOption[] {
|
|
32
|
+
const domain = this.labels.get(constantId);
|
|
33
|
+
if (!domain) throw new Error(`sail: constant domain "${constantId}" is not loaded.`);
|
|
34
|
+
return [...domain].map(([code, caption]) => ({ code, caption }))
|
|
35
|
+
.sort((a, b) => a.caption.localeCompare(b.caption));
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
/**
|
|
17
39
|
* Load one backend-provided `constant_value` domain from ApplicationData.
|
|
18
40
|
* Completed and concurrent requests are cached; a failed application-data
|
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import { Injectable, Signal, signal } from '@angular/core';
|
|
1
|
+
import { Injectable, Signal, inject, signal } from '@angular/core';
|
|
2
2
|
import { HttpParams } from '@angular/common/http';
|
|
3
|
-
import { Observable, tap } from 'rxjs';
|
|
3
|
+
import { EMPTY, Observable, Subscription, catchError, filter, switchMap, tap } from 'rxjs';
|
|
4
4
|
import { BaseRestService } from './base_rest.service';
|
|
5
|
+
import { RealtimeService } from './realtime.service';
|
|
5
6
|
import { RestURL } from './rest_url';
|
|
6
7
|
import { InboxMessage, InboxPage } from '../model/notification';
|
|
7
8
|
|
|
8
9
|
export const INBOX_MAX_PAGE_SIZE = 100;
|
|
9
10
|
export const INBOX_DEFAULT_PAGE_SIZE = 20;
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Stateful client for keel's `InboxHandler` (v1.2.71+). After `load()` it re-fetches
|
|
14
|
+
* the newest page on each realtime `notification` frame (keel `realtime.UserDispatcher`).
|
|
15
|
+
* Call `reset()` on logout.
|
|
16
|
+
*/
|
|
12
17
|
@Injectable({ providedIn: 'root' })
|
|
13
18
|
export class NotificationInboxService extends BaseRestService {
|
|
19
|
+
private readonly realtime = inject(RealtimeService);
|
|
20
|
+
private live: Subscription | null = null;
|
|
21
|
+
private pageSize = INBOX_DEFAULT_PAGE_SIZE;
|
|
14
22
|
private readonly cached = signal<InboxMessage[]>([]);
|
|
15
23
|
private readonly unread = signal(0);
|
|
16
24
|
private readonly older = signal(false);
|
|
@@ -20,17 +28,25 @@ export class NotificationInboxService extends BaseRestService {
|
|
|
20
28
|
readonly hasMore: Signal<boolean> = this.older.asReadonly();
|
|
21
29
|
|
|
22
30
|
load(limit = INBOX_DEFAULT_PAGE_SIZE): Observable<InboxPage> {
|
|
23
|
-
|
|
31
|
+
this.pageSize = pageSizeOf(limit);
|
|
32
|
+
return this.fetch('', limit).pipe(tap((page) => {
|
|
33
|
+
this.cached.set(page.messages);
|
|
34
|
+
this.older.set(this.isFull(page, limit));
|
|
35
|
+
this.follow();
|
|
36
|
+
}));
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
loadMore(limit = INBOX_DEFAULT_PAGE_SIZE): Observable<InboxPage> {
|
|
27
40
|
const loaded = this.cached();
|
|
28
41
|
const oldest = loaded.length ? loaded[loaded.length - 1].id : '';
|
|
29
42
|
return this.fetch(oldest, limit).pipe(
|
|
30
|
-
tap((page) =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
tap((page) => {
|
|
44
|
+
this.older.set(this.isFull(page, limit));
|
|
45
|
+
this.cached.update((rows) => {
|
|
46
|
+
const ids = new Set(rows.map((message) => message.id));
|
|
47
|
+
return rows.concat(page.messages.filter((message) => !ids.has(message.id)));
|
|
48
|
+
});
|
|
49
|
+
}),
|
|
34
50
|
);
|
|
35
51
|
}
|
|
36
52
|
|
|
@@ -54,23 +70,46 @@ export class NotificationInboxService extends BaseRestService {
|
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
reset(): void {
|
|
73
|
+
this.live?.unsubscribe();
|
|
74
|
+
this.live = null;
|
|
57
75
|
this.cached.set([]);
|
|
58
76
|
this.unread.set(0);
|
|
59
77
|
this.older.set(false);
|
|
60
78
|
}
|
|
61
79
|
|
|
80
|
+
/** The frame carries no message id, so the newest page is re-fetched and merged over the cache head. */
|
|
81
|
+
private follow(): void {
|
|
82
|
+
if (this.live) return;
|
|
83
|
+
this.live = this.realtime.userFrames<{ op?: unknown }>().pipe(
|
|
84
|
+
filter((frame) => frame?.op === 'notification'),
|
|
85
|
+
switchMap(() => this.fetch('', this.pageSize).pipe(
|
|
86
|
+
catchError((err) => {
|
|
87
|
+
console.error('notification inbox: refresh after realtime frame failed', err);
|
|
88
|
+
return EMPTY;
|
|
89
|
+
}),
|
|
90
|
+
)),
|
|
91
|
+
).subscribe((page) => {
|
|
92
|
+
const fresh = new Set(page.messages.map((message) => message.id));
|
|
93
|
+
this.cached.update((rows) => page.messages.concat(rows.filter((message) => !fresh.has(message.id))));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
62
97
|
private fetch(before: string, limit: number): Observable<InboxPage> {
|
|
63
|
-
|
|
64
|
-
const size = Math.min(Math.max(1, requested), INBOX_MAX_PAGE_SIZE);
|
|
65
|
-
let params = new HttpParams().set('limit', size);
|
|
98
|
+
let params = new HttpParams().set('limit', pageSizeOf(limit));
|
|
66
99
|
if (before) params = params.set('before', before);
|
|
67
100
|
return this.http.get<InboxPage>(this.url(RestURL.notificationsURL), { params }).pipe(
|
|
68
|
-
tap((page) =>
|
|
69
|
-
this.unread.set(page.unreadCount);
|
|
70
|
-
this.older.set(page.messages.length >= size);
|
|
71
|
-
}),
|
|
101
|
+
tap((page) => this.unread.set(page.unreadCount)),
|
|
72
102
|
);
|
|
73
103
|
}
|
|
104
|
+
|
|
105
|
+
private isFull(page: InboxPage, limit: number): boolean {
|
|
106
|
+
return page.messages.length >= pageSizeOf(limit);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pageSizeOf(limit: number): number {
|
|
111
|
+
const requested = Number.isFinite(limit) ? Math.trunc(limit) : INBOX_DEFAULT_PAGE_SIZE;
|
|
112
|
+
return Math.min(Math.max(1, requested), INBOX_MAX_PAGE_SIZE);
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
function nowIso(): string {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { inject, Injectable } from '@angular/core';
|
|
2
|
+
import { HttpEvent, HttpEventType, HttpParams, HttpResponse } from '@angular/common/http';
|
|
3
|
+
import { Observable, map } from 'rxjs';
|
|
4
|
+
import { BaseRestService } from './base_rest.service';
|
|
5
|
+
import { BackendService } from './rest_service';
|
|
6
|
+
import { RestURL } from './rest_url';
|
|
7
|
+
import { DocumentUploadRequest, PartnerDocument } from '../model/document';
|
|
8
|
+
|
|
9
|
+
interface DocumentHandlerResponse {
|
|
10
|
+
ID: number;
|
|
11
|
+
ContRepID: string;
|
|
12
|
+
DocKey: string;
|
|
13
|
+
PartnerID: number;
|
|
14
|
+
UserID: number;
|
|
15
|
+
DocumentType: string;
|
|
16
|
+
Title: string;
|
|
17
|
+
FileName: string;
|
|
18
|
+
VersionNo: number;
|
|
19
|
+
DocumentNumber: string;
|
|
20
|
+
ExpiresOn: string;
|
|
21
|
+
OriginIP: string;
|
|
22
|
+
UploadedBy: number;
|
|
23
|
+
UploadedAt: string;
|
|
24
|
+
Status: string;
|
|
25
|
+
ReviewerID: number;
|
|
26
|
+
ReviewedAt: string;
|
|
27
|
+
ReviewerNotes: string;
|
|
28
|
+
SupersededAt: string;
|
|
29
|
+
RetiredAt: string;
|
|
30
|
+
PurgedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Client for keel's `handler.DocumentHandler` and `partner_document` REST table. */
|
|
34
|
+
@Injectable({ providedIn: 'root' })
|
|
35
|
+
export class PartnerDocumentService extends BaseRestService {
|
|
36
|
+
private readonly backend = inject(BackendService);
|
|
37
|
+
|
|
38
|
+
list(userId?: number): Observable<PartnerDocument[]> {
|
|
39
|
+
const filter = userId === undefined ? undefined : { UserId: String(userId) };
|
|
40
|
+
return this.backend.list<PartnerDocument>('partner_document', filter);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pending(): Observable<PartnerDocument[]> {
|
|
44
|
+
return this.backend.list<PartnerDocument>('partner_document', { Status: 'P' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Multipart upload with progress events; the final response carries the stored document. */
|
|
48
|
+
upload(file: File, request: DocumentUploadRequest): Observable<HttpEvent<PartnerDocument>> {
|
|
49
|
+
const form = new FormData();
|
|
50
|
+
form.append('file', file);
|
|
51
|
+
form.append('document_type', request.documentType);
|
|
52
|
+
if (request.title) form.append('title', request.title);
|
|
53
|
+
if (request.documentNumber) form.append('document_number', request.documentNumber);
|
|
54
|
+
if (request.expiresOn) form.append('expires_on', request.expiresOn);
|
|
55
|
+
if (request.userId) form.append('user_id', String(request.userId));
|
|
56
|
+
return this.http.post<DocumentHandlerResponse>(this.url(RestURL.partnerDocumentUploadURL), form, {
|
|
57
|
+
reportProgress: true,
|
|
58
|
+
observe: 'events',
|
|
59
|
+
}).pipe(map((event) => normalizeUploadEvent(event)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Short-lived signed read URL. */
|
|
63
|
+
previewUrl(id: number): Observable<string> {
|
|
64
|
+
const params = new HttpParams().set('id', id);
|
|
65
|
+
return this.http.get<{ url: string }>(this.url(RestURL.partnerDocumentPreviewURL), { params })
|
|
66
|
+
.pipe(map((resp) => resp.url));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
review(id: number, approve: boolean, notes = ''): Observable<void> {
|
|
70
|
+
// keel decodes the id with `json:"id,string"`.
|
|
71
|
+
return this.http.post<void>(this.url(RestURL.partnerDocumentReviewURL), { id: String(id), approve, notes });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeUploadEvent(event: HttpEvent<DocumentHandlerResponse>): HttpEvent<PartnerDocument> {
|
|
76
|
+
if (event.type !== HttpEventType.Response) return event;
|
|
77
|
+
return (event as HttpResponse<DocumentHandlerResponse>).clone({
|
|
78
|
+
body: event.body ? normalizeDocument(event.body) : null,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** DocumentHandler answers with keel's Go struct: zero values stand for the row's NULLs. */
|
|
83
|
+
function normalizeDocument(doc: DocumentHandlerResponse): PartnerDocument {
|
|
84
|
+
return {
|
|
85
|
+
Id: doc.ID,
|
|
86
|
+
ContrepId: doc.ContRepID,
|
|
87
|
+
DocKey: doc.DocKey,
|
|
88
|
+
PartnerId: doc.PartnerID,
|
|
89
|
+
UserId: doc.UserID || null,
|
|
90
|
+
DocumentType: doc.DocumentType,
|
|
91
|
+
Title: doc.Title,
|
|
92
|
+
FileName: doc.FileName,
|
|
93
|
+
VersionNo: doc.VersionNo,
|
|
94
|
+
DocumentNumber: doc.DocumentNumber || null,
|
|
95
|
+
ExpiresOn: timeOrNull(doc.ExpiresOn),
|
|
96
|
+
OriginIp: doc.OriginIP || null,
|
|
97
|
+
UploadedBy: doc.UploadedBy || null,
|
|
98
|
+
UploadedAt: doc.UploadedAt,
|
|
99
|
+
Status: doc.Status,
|
|
100
|
+
ReviewerId: doc.ReviewerID || null,
|
|
101
|
+
ReviewedAt: timeOrNull(doc.ReviewedAt),
|
|
102
|
+
ReviewerNotes: doc.ReviewerNotes || null,
|
|
103
|
+
SupersededAt: timeOrNull(doc.SupersededAt),
|
|
104
|
+
RetiredAt: timeOrNull(doc.RetiredAt),
|
|
105
|
+
PurgedAt: timeOrNull(doc.PurgedAt),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function timeOrNull(value: string): string | null {
|
|
110
|
+
return !value || value.startsWith('0001-01-01') ? null : value;
|
|
111
|
+
}
|
|
@@ -8,9 +8,11 @@ export interface RealtimeChannelFrame<T = unknown> {
|
|
|
8
8
|
data: T;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
const CONTROL_OPS = ['subscribed', 'unsubscribed', 'error'] as const;
|
|
12
|
+
|
|
11
13
|
/** Control frames of the keel realtime wire protocol. */
|
|
12
14
|
interface ControlFrame {
|
|
13
|
-
op:
|
|
15
|
+
op: typeof CONTROL_OPS[number];
|
|
14
16
|
channel?: string;
|
|
15
17
|
reason?: string;
|
|
16
18
|
}
|
|
@@ -93,7 +95,7 @@ export class RealtimeService {
|
|
|
93
95
|
if (this.channels.delete(channel) && this.connected()) this.sendFrame({ op: 'unsubscribe', channel });
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
/** Payloads keel delivers to this user directly (SendToUser / PublishUser). */
|
|
98
|
+
/** Payloads keel delivers to this user directly (SendToUser / PublishUser), e.g. `{op: 'notification', ...}`. */
|
|
97
99
|
userFrames<T = unknown>(): Observable<T> {
|
|
98
100
|
return this.frames$.pipe(filter((f): f is T => !isChannelFrame(f) && !isControlFrame(f)));
|
|
99
101
|
}
|
|
@@ -132,7 +134,7 @@ function isChannelFrame(f: unknown): f is RealtimeChannelFrame {
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
function isControlFrame(f: unknown): f is ControlFrame {
|
|
135
|
-
return !!f && typeof f === 'object' && 'op'
|
|
137
|
+
return !!f && typeof f === 'object' && CONTROL_OPS.includes((f as { op?: unknown }).op as ControlFrame['op']);
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
/** ws(s):// form of the configured backend host plus `path`; same-origin when no host is set. */
|
|
@@ -153,8 +153,18 @@ export class BackendService extends BaseRestService {
|
|
|
153
153
|
* the call will POST to {host}{api_prefix}{actionPath} with the
|
|
154
154
|
* supplied body. For record-specific actions the body carries the
|
|
155
155
|
* primary key values; for table-level actions it is typically `{}`.
|
|
156
|
+
* A body carrying a File/Blob posts as `multipart/form-data`.
|
|
156
157
|
*/
|
|
157
158
|
executeAction<T = unknown>(actionPath: string, body: Record<string, unknown> = {}): Observable<T> {
|
|
158
|
-
return this.http.post<T>(this.apiUrl(actionPath), body).pipe(catchError((err) => this.handleError(err)));
|
|
159
|
+
return this.http.post<T>(this.apiUrl(actionPath), multipartWhenFile(body)).pipe(catchError((err) => this.handleError(err)));
|
|
159
160
|
}
|
|
160
161
|
}
|
|
162
|
+
|
|
163
|
+
function multipartWhenFile(body: Record<string, unknown>): Record<string, unknown> | FormData {
|
|
164
|
+
if (!Object.values(body).some((value) => value instanceof Blob)) return body;
|
|
165
|
+
const form = new FormData();
|
|
166
|
+
for (const [key, value] of Object.entries(body)) {
|
|
167
|
+
if (value != null) form.append(key, value instanceof Blob ? value : String(value));
|
|
168
|
+
}
|
|
169
|
+
return form;
|
|
170
|
+
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -77,11 +77,14 @@ export const RestURL = {
|
|
|
77
77
|
agencyAcceptInviteURL: '/api/v1/agency/invite/accept',
|
|
78
78
|
agencyDelegationURL: '/api/v1/agency/delegation',
|
|
79
79
|
agencyRevokeDelegationURL: '/api/v1/agency/delegation/revoke',
|
|
80
|
-
// User payment methods — list
|
|
81
|
-
//
|
|
82
|
-
// TableAction (basis table_action row), mounted at the conventional
|
|
83
|
-
// URL POST /api/v1/user_payment_method/set_default.
|
|
80
|
+
// User payment methods — list is keel's generic REST CRUD; set_default and
|
|
81
|
+
// remove are keel UserPaymentMethodHandler table actions.
|
|
84
82
|
paymentMethodSetDefaultURL: '/api/v1/user_payment_method/set_default',
|
|
83
|
+
paymentMethodRemoveURL: '/api/v1/user_payment_method/remove',
|
|
84
|
+
// Partner documents — keel DocumentHandler, mounted at its TableActionPath shape.
|
|
85
|
+
partnerDocumentUploadURL: '/api/v1/partner_document/upload',
|
|
86
|
+
partnerDocumentPreviewURL: '/api/v1/partner_document/preview',
|
|
87
|
+
partnerDocumentReviewURL: '/api/v1/partner_document/review',
|
|
85
88
|
};
|
|
86
89
|
|
|
87
90
|
/** Call this at app startup to set the backend host URL */
|
|
@@ -8,13 +8,10 @@ import { UserPaymentMethod } from '../model/appdata';
|
|
|
8
8
|
/**
|
|
9
9
|
* UserPaymentMethodService — consumer-side saved-payment-methods API.
|
|
10
10
|
*
|
|
11
|
-
* list
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* endpoint because it needs an atomic multi-row UPDATE
|
|
16
|
-
* (`SET is_default = (id = ?) WHERE user_id = session`) that abstract
|
|
17
|
-
* single-row CRUD can't express.
|
|
11
|
+
* list goes through keel's generic REST CRUD against the UserSpecific basis
|
|
12
|
+
* user_payment_method table. remove and setDefault are keel table actions:
|
|
13
|
+
* remove detaches the method at the provider before deleting the row, so keel
|
|
14
|
+
* grants no generic DELETE on the table.
|
|
18
15
|
*/
|
|
19
16
|
@Injectable({ providedIn: 'root' })
|
|
20
17
|
export class UserPaymentMethodService extends BaseRestService {
|
|
@@ -29,15 +26,12 @@ export class UserPaymentMethodService extends BaseRestService {
|
|
|
29
26
|
return this.backend.list<UserPaymentMethod>('user_payment_method');
|
|
30
27
|
}
|
|
31
28
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
return this.
|
|
29
|
+
/** Detach a saved payment method at the provider and delete it. */
|
|
30
|
+
remove(id: number): Observable<{ message: string }> {
|
|
31
|
+
return this.http.post<{ message: string }>(this.url(RestURL.paymentMethodRemoveURL), { id });
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
/**
|
|
38
|
-
* Mark one row as default; clears every other row in the same call.
|
|
39
|
-
* Custom endpoint — see service-class doc for the rationale.
|
|
40
|
-
*/
|
|
34
|
+
/** Mark one row as default; clears every other row in the same call. */
|
|
41
35
|
setDefault(id: number): Observable<{ message: string }> {
|
|
42
36
|
return this.http.post<{ message: string }>(this.url(RestURL.paymentMethodSetDefaultURL), { id });
|
|
43
37
|
}
|