@nauticana/sail 1.1.25 → 1.1.27
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 +68 -12
- package/package.json +2 -1
- package/src/component/billing/portal_button.ts +6 -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/login/login_component.ts +3 -3
- 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/config.ts +5 -5
- package/src/index.ts +5 -0
- package/src/model/agency.ts +8 -0
- package/src/model/document.ts +36 -0
- package/src/service/agency.service.ts +7 -0
- package/src/service/auth.service.ts +55 -21
- package/src/service/billing.service.ts +3 -3
- 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 +8 -4
- package/src/service/user_payment_method.service.ts +8 -14
- package/src/util/errors.ts +5 -0
|
@@ -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/config.ts
CHANGED
|
@@ -67,11 +67,11 @@ export interface SailGuiConfig {
|
|
|
67
67
|
accountDeletedRoute?: string;
|
|
68
68
|
|
|
69
69
|
// ── OAuth authorization-flow bridge (for an OAuth AS on a different host) ──
|
|
70
|
-
/**
|
|
71
|
-
* a
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
/** Hostnames a post-login `?return=` may
|
|
70
|
+
/** Base URL of keel's OAuth authorization server, e.g. 'https://mcp.example.com'.
|
|
71
|
+
* When set, a login carrying an allowed ?return= POSTs it to
|
|
72
|
+
* `{oauthServerUrl}/oauth/session/handoff` and navigates to the URL keel answers. */
|
|
73
|
+
oauthServerUrl?: string;
|
|
74
|
+
/** Hostnames a post-login `?return=` may name (open-redirect allowlist),
|
|
75
75
|
* e.g. ['mcp.example.com']. Anything not listed is ignored. */
|
|
76
76
|
allowedReturnHosts?: string[];
|
|
77
77
|
|
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';
|
package/src/model/agency.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface AgencyClient {
|
|
|
24
24
|
acceptedAt?: string;
|
|
25
25
|
expired?: boolean;
|
|
26
26
|
billingModel?: string;
|
|
27
|
+
roles?: AgencyRoleGrant[];
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface AgencyInvite {
|
|
@@ -42,6 +43,13 @@ export interface AgencyDelegation {
|
|
|
42
43
|
agencyName: string;
|
|
43
44
|
billingModel?: string;
|
|
44
45
|
grantedAt: string;
|
|
46
|
+
roles: AgencyRoleGrant[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One `agency_delegation_role` code a client grants its agency; no expiresAt is open-ended. */
|
|
50
|
+
export interface AgencyRoleGrant {
|
|
51
|
+
role: string;
|
|
52
|
+
expiresAt?: string;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export interface AgencyEarningsBalance {
|
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
AgencyInvite,
|
|
9
9
|
AgencyPayoutProfileOptions,
|
|
10
10
|
AgencyProfile,
|
|
11
|
+
AgencyRoleGrant,
|
|
11
12
|
} from '../model/agency';
|
|
12
13
|
import { BaseRestService } from './base_rest.service';
|
|
13
14
|
import { RestURL } from './rest_url';
|
|
@@ -73,4 +74,10 @@ export class AgencyService extends BaseRestService {
|
|
|
73
74
|
revokeDelegation(clientPartnerId = 0): Observable<{ status: string }> {
|
|
74
75
|
return this.http.post<{ status: string }>(this.url(RestURL.agencyRevokeDelegationURL), { clientPartnerId });
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
/** Replace the whole role set the client grants its agency; an empty list
|
|
79
|
+
* removes access without revoking the delegation. */
|
|
80
|
+
setDelegationRoles(roles: AgencyRoleGrant[], clientPartnerId = 0): Observable<{ status: string }> {
|
|
81
|
+
return this.http.post<{ status: string }>(this.url(RestURL.agencyDelegationRolesURL), { clientPartnerId, roles });
|
|
82
|
+
}
|
|
76
83
|
}
|
|
@@ -25,6 +25,20 @@ export class RefreshSupersededError extends Error {
|
|
|
25
25
|
constructor() { super('session changed during refresh'); this.name = 'RefreshSupersededError'; }
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function handoffErrorMessage(status: number): string {
|
|
29
|
+
if (status === 400) return 'This sign-in link is not valid. Start again from the application you were connecting.';
|
|
30
|
+
if (status === 401) return 'Your session was not accepted. Please sign in again.';
|
|
31
|
+
return 'Could not complete sign-in. Please try again.';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sameOrigin(a: string, b: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(a, window.location.origin).origin === new URL(b, window.location.origin).origin;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
/** Sort dropdown options by their visible label so long lookup lists are scannable. */
|
|
29
43
|
const byCaption = (a: ConstantValue, b: ConstantValue) =>
|
|
30
44
|
(a.Caption ?? '').localeCompare(b.Caption ?? '', undefined, { numeric: true, sensitivity: 'base' });
|
|
@@ -124,8 +138,8 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
124
138
|
/** Last appdata load failure (null when none). */
|
|
125
139
|
readonly appDataError = signal<unknown>(null);
|
|
126
140
|
|
|
127
|
-
/** Set when the OAuth session-
|
|
128
|
-
*
|
|
141
|
+
/** Set when the OAuth session hand-off fails; the UI shows it and the user
|
|
142
|
+
* signs in again to retry instead of being redirected without a session. */
|
|
129
143
|
readonly sessionHandoffError = signal<string | null>(null);
|
|
130
144
|
|
|
131
145
|
/**
|
|
@@ -203,42 +217,61 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
203
217
|
this.sessionGeneration++;
|
|
204
218
|
this.storeTokens(token, refreshToken);
|
|
205
219
|
this.isLoggedIn.set(true);
|
|
220
|
+
this.sessionHandoffError.set(null);
|
|
206
221
|
resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
|
|
207
222
|
const ret = this.validatedReturnUrl();
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// once the cookie is actually set — redirecting after a failed cookie request
|
|
212
|
-
// sends the user on without a session and loops them back to login.
|
|
213
|
-
this.sessionHandoffError.set(null);
|
|
214
|
-
this.http.post(this.url(this.guiConfig.sessionCookieUrl), {}, { withCredentials: true })
|
|
215
|
-
.pipe(take(1))
|
|
216
|
-
.subscribe({
|
|
217
|
-
next: () => window.location.assign(ret),
|
|
218
|
-
error: (err) => {
|
|
219
|
-
console.error('Session cookie handoff failed:', err);
|
|
220
|
-
this.sessionHandoffError.set('Could not complete sign-in. Please try again.');
|
|
221
|
-
},
|
|
222
|
-
});
|
|
223
|
+
const oauthServer = this.oauthServerBase();
|
|
224
|
+
if (ret && oauthServer) {
|
|
225
|
+
this.handOffSession(oauthServer, ret, token);
|
|
223
226
|
return;
|
|
224
227
|
}
|
|
225
228
|
this.loadAppData();
|
|
226
229
|
this.initRoutes();
|
|
227
230
|
}
|
|
228
231
|
|
|
229
|
-
/**
|
|
230
|
-
*
|
|
232
|
+
/** Exchange the bearer session for the authorization server's own cookie session
|
|
233
|
+
* (keel POST /oauth/session/handoff), then leave the SPA for the URL it returns.
|
|
234
|
+
* Any failure stays on the login page; the browser is never sent elsewhere. */
|
|
235
|
+
private handOffSession(oauthServer: string, ret: string, token: string): void {
|
|
236
|
+
this.http.post<{ data?: { redirect?: unknown } }>(
|
|
237
|
+
oauthServer + '/oauth/session/handoff',
|
|
238
|
+
{ return: ret },
|
|
239
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
240
|
+
).pipe(take(1)).subscribe({
|
|
241
|
+
next: (res) => {
|
|
242
|
+
const redirect = res?.data?.redirect;
|
|
243
|
+
if (typeof redirect !== 'string' || !sameOrigin(redirect, oauthServer)) {
|
|
244
|
+
console.error('Session handoff returned an unexpected redirect:', redirect);
|
|
245
|
+
this.sessionHandoffError.set('Could not complete sign-in. Please try again.');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
window.location.assign(redirect);
|
|
249
|
+
},
|
|
250
|
+
error: (err: HttpErrorResponse) => {
|
|
251
|
+
console.error('Session handoff failed:', err);
|
|
252
|
+
this.sessionHandoffError.set(handoffErrorMessage(err.status));
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** guiConfig.oauthServerUrl without trailing slashes; '' when unset. */
|
|
258
|
+
private oauthServerBase(): string {
|
|
259
|
+
return (this.guiConfig.oauthServerUrl ?? '').replace(/\/+$/, '');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The raw post-login `?return=` value, only when its host is in allowedReturnHosts
|
|
263
|
+
* and the scheme is https (or loopback) — an open-redirect guard. '' otherwise. */
|
|
231
264
|
private validatedReturnUrl(): string {
|
|
232
265
|
const raw = new URLSearchParams(window.location.search).get('return');
|
|
233
266
|
if (!raw) return '';
|
|
234
267
|
try {
|
|
235
|
-
const u = new URL(raw
|
|
268
|
+
const u = new URL(raw);
|
|
236
269
|
if (u.username || u.password) return ''; // reject userinfo ("user@host") spoofing
|
|
237
270
|
const host = u.hostname.toLowerCase();
|
|
238
271
|
const okScheme = u.protocol === 'https:' || host === 'localhost' || host === '127.0.0.1';
|
|
239
272
|
const allowed = (this.guiConfig.allowedReturnHosts ?? []).some((h) => h.toLowerCase() === host);
|
|
240
273
|
if (okScheme && allowed) {
|
|
241
|
-
return
|
|
274
|
+
return raw;
|
|
242
275
|
}
|
|
243
276
|
} catch { /* malformed → reject */ }
|
|
244
277
|
return '';
|
|
@@ -564,6 +597,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
564
597
|
this.cache = undefined;
|
|
565
598
|
this.authIndex.clear();
|
|
566
599
|
this.appDataError.set(null);
|
|
600
|
+
this.sessionHandoffError.set(null);
|
|
567
601
|
this.refreshInFlight = null;
|
|
568
602
|
this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
|
|
569
603
|
this.appData$.next(null); // live subscribers see menus clear; identity stays stable
|
|
@@ -45,7 +45,7 @@ export class BillingService extends BaseRestService {
|
|
|
45
45
|
return this.http.get<Subscription>(this.url(RestURL.subscriptionURL));
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** Cancel auto-renew on the current subscription. */
|
|
48
|
+
/** Cancel auto-renew on the current subscription. 403 without PARTNER_PLAN_SUBSCRIPTION / CANCEL. */
|
|
49
49
|
cancelSubscription(): Observable<void> {
|
|
50
50
|
return this.http.post<void>(this.url(RestURL.cancelSubURL), {});
|
|
51
51
|
}
|
|
@@ -53,7 +53,7 @@ export class BillingService extends BaseRestService {
|
|
|
53
53
|
/**
|
|
54
54
|
* Upgrade / downgrade the current subscription to `planId` — distinct from
|
|
55
55
|
* first-time checkout. Routes to keel's `SubscriptionLifecycle.ChangePlan`
|
|
56
|
-
* (atomic close+open)
|
|
56
|
+
* (atomic close+open). 403 without PARTNER_PLAN_SUBSCRIPTION / CHANGE.
|
|
57
57
|
*/
|
|
58
58
|
changePlan(planId: string): Observable<void> {
|
|
59
59
|
return this.http.post<void>(this.url(RestURL.changePlanURL), { planId });
|
|
@@ -86,7 +86,7 @@ export class BillingService extends BaseRestService {
|
|
|
86
86
|
/**
|
|
87
87
|
* Create a provider customer-portal session; caller redirects to the URL so
|
|
88
88
|
* the partner can manage their payment method / subscription on the
|
|
89
|
-
* provider's hosted page.
|
|
89
|
+
* provider's hosted page. 403 without PARTNER_PLAN_SUBSCRIPTION / PORTAL.
|
|
90
90
|
*/
|
|
91
91
|
createPortalSession(): Observable<PortalResponse> {
|
|
92
92
|
return this.http.post<PortalResponse>(this.url(RestURL.portalURL), {});
|
|
@@ -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,15 @@ 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
|
-
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// URL POST /api/v1/user_payment_method/set_default.
|
|
80
|
+
agencyDelegationRolesURL: '/api/v1/agency/delegation/roles',
|
|
81
|
+
// User payment methods — list is keel's generic REST CRUD; set_default and
|
|
82
|
+
// remove are keel UserPaymentMethodHandler table actions.
|
|
84
83
|
paymentMethodSetDefaultURL: '/api/v1/user_payment_method/set_default',
|
|
84
|
+
paymentMethodRemoveURL: '/api/v1/user_payment_method/remove',
|
|
85
|
+
// Partner documents — keel DocumentHandler, mounted at its TableActionPath shape.
|
|
86
|
+
partnerDocumentUploadURL: '/api/v1/partner_document/upload',
|
|
87
|
+
partnerDocumentPreviewURL: '/api/v1/partner_document/preview',
|
|
88
|
+
partnerDocumentReviewURL: '/api/v1/partner_document/review',
|
|
85
89
|
};
|
|
86
90
|
|
|
87
91
|
/** Call this at app startup to set the backend host URL */
|