@flowdular/sdk 0.3.1 → 0.3.2
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/.ai/platform-capabilities.md +2 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
- package/.ai/references/catalog/module.json +11 -1
- package/.ai/references/catalog/package.json +2 -2
- package/.ai/references/catalog/spec/module.yaml +25 -4
- package/.ai/references/catalog/src/agent/tools.ts +19 -10
- package/.ai/references/catalog/src/api/endpoints.ts +150 -10
- package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
- package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
- package/.ai/references/catalog/src/client/api.ts +124 -36
- package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
- package/.ai/references/catalog/src/client/state.ts +169 -3
- package/.ai/references/catalog/src/domain/lists.ts +7 -0
- package/.ai/references/catalog/src/domain/types.ts +20 -0
- package/.ai/references/catalog/src/platform.ts +20 -0
- package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
- package/.ai/references/catalog/src/services/database-repository.ts +104 -17
- package/.ai/references/catalog/src/services/item-export.ts +81 -0
- package/.ai/references/catalog/src/services/migration.ts +27 -1
- package/.ai/references/catalog/src/services/repository.ts +31 -2
- package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
- package/.ai/references/catalog/tests/client-state.test.ts +124 -0
- package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
- package/.ai/references/catalog/tests/export.test.ts +134 -0
- package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
- package/.ai/references/catalog/tests/list.test.ts +217 -0
- package/.ai/references/catalog/tests/migrations.test.ts +58 -2
- package/.ai/references/catalog/tests/module.test.ts +2 -1
- package/.ai/references/catalog/tests/support/database.ts +14 -0
- package/.ai/references/catalog/translations/en.json +35 -4
- package/.ai/references/catalog/translations/pl.json +35 -4
- package/.ai/references/catalog.provenance.json +34 -26
- package/README.md +1 -1
- package/assets/flowdular-banner.webp +0 -0
- package/modules/access/module.json +1 -1
- package/modules/access/spec/module.yaml +1 -1
- package/modules/agents/module.json +1 -1
- package/modules/agents/spec/module.yaml +1 -1
- package/modules/approvals/module.json +1 -1
- package/modules/approvals/spec/module.yaml +1 -1
- package/modules/audit/module.json +1 -1
- package/modules/audit/spec/module.yaml +1 -1
- package/modules/auth/module.json +2 -2
- package/modules/auth/package.json +1 -1
- package/modules/auth/spec/module.yaml +2 -2
- package/modules/auth/src/middleware/authentication.ts +5 -1
- package/modules/auth/src/services/auth-service.ts +26 -0
- package/modules/auth/src/services/database-repository.ts +9 -0
- package/modules/auth/src/services/repository.ts +2 -0
- package/modules/automations/module.json +1 -1
- package/modules/automations/spec/module.yaml +1 -1
- package/modules/connectors/module.json +1 -1
- package/modules/connectors/spec/module.yaml +1 -1
- package/modules/directory/module.json +1 -1
- package/modules/directory/spec/module.yaml +1 -1
- package/modules/documents/module.json +1 -1
- package/modules/documents/spec/module.yaml +1 -1
- package/modules/exports/module.json +1 -1
- package/modules/exports/spec/module.yaml +1 -1
- package/modules/import/module.json +1 -1
- package/modules/import/spec/module.yaml +1 -1
- package/modules/metering/module.json +1 -1
- package/modules/metering/spec/module.yaml +1 -1
- package/modules/notifications/module.json +1 -1
- package/modules/notifications/spec/module.yaml +1 -1
- package/modules/reports/module.json +1 -1
- package/modules/reports/spec/module.yaml +1 -1
- package/modules/sandbox/module.json +1 -1
- package/modules/sandbox/spec/module.yaml +1 -1
- package/modules/search/module.json +1 -1
- package/modules/search/spec/module.yaml +1 -1
- package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
- package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
- package/modules/system/module.json +3 -2
- package/modules/system/package.json +2 -2
- package/modules/system/spec/module.yaml +98 -1
- package/modules/system/src/client/ModulesView.tsrx +101 -8
- package/modules/system/src/client/api.ts +52 -11
- package/modules/system/src/client/module-columns.tsrx +32 -6
- package/modules/system/src/client/state.ts +26 -0
- package/modules/system/src/domain/modules.ts +93 -0
- package/modules/system/src/index.ts +13 -0
- package/modules/system/src/platform.ts +41 -1
- package/modules/system/src/server/capability.ts +6 -0
- package/modules/system/src/server/endpoints.ts +125 -5
- package/modules/system/src/server/index.ts +26 -0
- package/modules/system/src/server/module-catalog.ts +19 -0
- package/modules/system/src/server/runtime.ts +102 -0
- package/modules/system/src/services/database-repository.ts +91 -0
- package/modules/system/src/services/migration.ts +39 -0
- package/modules/system/src/services/module-activation-service.ts +245 -0
- package/modules/system/src/services/repository.ts +17 -0
- package/modules/system/translations/en.json +17 -1
- package/modules/system/translations/pl.json +17 -1
- package/modules/users/module.json +2 -2
- package/modules/users/package.json +1 -1
- package/modules/users/spec/module.yaml +6 -9
- package/modules/users/src/client/UsersView.tsrx +27 -13
- package/modules/users/src/client/member-columns.tsrx +13 -4
- package/modules/users/src/client/state.ts +16 -0
- package/modules/users/src/services/users-service.ts +3 -0
- package/modules/users/translations/en.json +2 -1
- package/modules/users/translations/pl.json +2 -1
- package/modules/workflows/module.json +2 -2
- package/modules/workflows/package.json +1 -1
- package/modules/workflows/spec/module.yaml +2 -2
- package/modules/workflows/src/services/database-repository.ts +93 -53
- package/package.json +1 -1
- package/packages/client/package.json +1 -0
- package/packages/client/src/ApplicationShell.tsrx +46 -5
- package/packages/client/src/index.ts +5 -0
- package/packages/client/src/shell/modules.ts +49 -0
- package/packages/client/src/shell/types.ts +3 -0
- package/packages/contracts/src/index.ts +11 -1
- package/packages/server/src/endpoint.ts +17 -1
- package/packages/server/src/index.ts +10 -0
- package/packages/server/src/module-activation.ts +69 -0
- package/assets/flowdular-banner.png +0 -0
|
@@ -1,46 +1,109 @@
|
|
|
1
1
|
import { t } from '@flowdular/sdk/client/i18n';
|
|
2
2
|
import type { HistoryPage } from '@flowdular/sdk/kernel';
|
|
3
3
|
import type {
|
|
4
|
+
CatalogBulkOutcome,
|
|
4
5
|
CatalogItem,
|
|
6
|
+
CatalogItemKind,
|
|
7
|
+
CatalogItemStatus,
|
|
8
|
+
CatalogListSort,
|
|
5
9
|
CreateCatalogItemInput,
|
|
6
10
|
UpdateCatalogItemInput,
|
|
7
11
|
} from '../domain/types.ts';
|
|
8
12
|
|
|
9
13
|
interface ErrorEnvelope {
|
|
10
|
-
readonly error?: { readonly message?: string };
|
|
14
|
+
readonly error?: { readonly code?: string; readonly message?: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ApiError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
/** Stable server code; '' when the response carried none. */
|
|
20
|
+
readonly code: string;
|
|
21
|
+
|
|
22
|
+
constructor(status: number, message: string, code = '') {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'ApiError';
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
11
28
|
}
|
|
12
29
|
|
|
13
30
|
async function payload<T>(response: Response): Promise<T> {
|
|
14
31
|
const value = (await response.json()) as T & ErrorEnvelope;
|
|
15
32
|
if (!response.ok) {
|
|
16
|
-
throw new
|
|
33
|
+
throw new ApiError(
|
|
34
|
+
response.status,
|
|
35
|
+
value.error?.message ?? t('catalog.error.request'),
|
|
36
|
+
value.error?.code ?? '',
|
|
37
|
+
);
|
|
17
38
|
}
|
|
18
39
|
return value;
|
|
19
40
|
}
|
|
20
41
|
|
|
21
|
-
|
|
22
|
-
|
|
42
|
+
async function post<T>(
|
|
43
|
+
path: string,
|
|
44
|
+
body: unknown,
|
|
45
|
+
csrfToken: string,
|
|
46
|
+
): Promise<T> {
|
|
47
|
+
const response = await fetch(path, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
'content-type': 'application/json',
|
|
51
|
+
'x-csrf-token': csrfToken,
|
|
52
|
+
},
|
|
53
|
+
credentials: 'same-origin',
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
});
|
|
56
|
+
return payload<T>(response);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CatalogListRequest {
|
|
60
|
+
readonly sort: CatalogListSort;
|
|
61
|
+
readonly direction: 'asc' | 'desc';
|
|
62
|
+
readonly limit: number;
|
|
63
|
+
/** A substring of the name or the SKU; '' narrows nothing. */
|
|
64
|
+
readonly query: string;
|
|
65
|
+
readonly kind: '' | CatalogItemKind;
|
|
66
|
+
readonly status: '' | CatalogItemStatus;
|
|
67
|
+
/** The cursor that opens this page; null asks for the first one. */
|
|
68
|
+
readonly cursor: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CatalogListPage {
|
|
72
|
+
readonly items: readonly CatalogItem[];
|
|
73
|
+
readonly page: { readonly nextCursor: string | null; readonly limit: number };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One server-sorted, server-narrowed, server-paged listing; the screen narrows nothing. */
|
|
77
|
+
export async function loadCatalogItems(
|
|
78
|
+
request: CatalogListRequest,
|
|
79
|
+
): Promise<CatalogListPage> {
|
|
80
|
+
const parameters = new URLSearchParams({
|
|
81
|
+
sort: request.sort,
|
|
82
|
+
direction: request.direction,
|
|
83
|
+
limit: String(request.limit),
|
|
84
|
+
});
|
|
85
|
+
if (request.query !== '') parameters.set('q', request.query);
|
|
86
|
+
if (request.kind !== '') parameters.set('kind', request.kind);
|
|
87
|
+
if (request.status !== '') parameters.set('status', request.status);
|
|
88
|
+
if (request.cursor !== null) parameters.set('cursor', request.cursor);
|
|
89
|
+
const response = await fetch('/api/catalog/items?' + parameters.toString(), {
|
|
23
90
|
headers: { accept: 'application/json' },
|
|
24
91
|
credentials: 'same-origin',
|
|
25
92
|
});
|
|
26
|
-
return
|
|
27
|
-
.items;
|
|
93
|
+
return payload<CatalogListPage>(response);
|
|
28
94
|
}
|
|
29
95
|
|
|
30
96
|
export async function createCatalogItem(
|
|
31
97
|
input: CreateCatalogItemInput,
|
|
32
98
|
csrfToken: string,
|
|
33
99
|
): Promise<CatalogItem> {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
body: JSON.stringify(input),
|
|
42
|
-
});
|
|
43
|
-
return (await payload<{ readonly item: CatalogItem }>(response)).item;
|
|
100
|
+
return (
|
|
101
|
+
await post<{ readonly item: CatalogItem }>(
|
|
102
|
+
'/api/catalog/items',
|
|
103
|
+
input,
|
|
104
|
+
csrfToken,
|
|
105
|
+
)
|
|
106
|
+
).item;
|
|
44
107
|
}
|
|
45
108
|
|
|
46
109
|
async function mutateItem(
|
|
@@ -48,16 +111,8 @@ async function mutateItem(
|
|
|
48
111
|
body: unknown,
|
|
49
112
|
csrfToken: string,
|
|
50
113
|
): Promise<CatalogItem> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
headers: {
|
|
54
|
-
'content-type': 'application/json',
|
|
55
|
-
'x-csrf-token': csrfToken,
|
|
56
|
-
},
|
|
57
|
-
credentials: 'same-origin',
|
|
58
|
-
body: JSON.stringify(body),
|
|
59
|
-
});
|
|
60
|
-
return (await payload<{ readonly item: CatalogItem }>(response)).item;
|
|
114
|
+
return (await post<{ readonly item: CatalogItem }>(path, body, csrfToken))
|
|
115
|
+
.item;
|
|
61
116
|
}
|
|
62
117
|
|
|
63
118
|
export function updateCatalogItem(
|
|
@@ -81,20 +136,53 @@ export function restoreCatalogItem(
|
|
|
81
136
|
return mutateItem('/api/catalog/items/restore', { id }, csrfToken);
|
|
82
137
|
}
|
|
83
138
|
|
|
139
|
+
type OutcomesResponse = { readonly outcomes: readonly CatalogBulkOutcome[] };
|
|
140
|
+
|
|
141
|
+
/* The selected rows, one outcome per id. */
|
|
142
|
+
export async function archiveCatalogItems(
|
|
143
|
+
ids: readonly string[],
|
|
144
|
+
csrfToken: string,
|
|
145
|
+
): Promise<readonly CatalogBulkOutcome[]> {
|
|
146
|
+
return (
|
|
147
|
+
await post<OutcomesResponse>(
|
|
148
|
+
'/api/catalog/items/archive-many',
|
|
149
|
+
{ ids },
|
|
150
|
+
csrfToken,
|
|
151
|
+
)
|
|
152
|
+
).outcomes;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function restoreCatalogItems(
|
|
156
|
+
ids: readonly string[],
|
|
157
|
+
csrfToken: string,
|
|
158
|
+
): Promise<readonly CatalogBulkOutcome[]> {
|
|
159
|
+
return (
|
|
160
|
+
await post<OutcomesResponse>(
|
|
161
|
+
'/api/catalog/items/restore-many',
|
|
162
|
+
{ ids },
|
|
163
|
+
csrfToken,
|
|
164
|
+
)
|
|
165
|
+
).outcomes;
|
|
166
|
+
}
|
|
167
|
+
|
|
84
168
|
export async function deleteCatalogItem(
|
|
85
169
|
id: string,
|
|
86
170
|
csrfToken: string,
|
|
87
171
|
): Promise<void> {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
172
|
+
await post<{ readonly deleted: true }>(
|
|
173
|
+
'/api/catalog/items/delete',
|
|
174
|
+
{ id },
|
|
175
|
+
csrfToken,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* exports.core owns the job, its file and the screen that hands it over, so the
|
|
180
|
+
Catalog screen only starts one. */
|
|
181
|
+
export async function startListExport(
|
|
182
|
+
list: string,
|
|
183
|
+
csrfToken: string,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
await post<unknown>('/api/exports/start', { list }, csrfToken);
|
|
98
186
|
}
|
|
99
187
|
|
|
100
188
|
export async function loadCatalogItemHistory(
|
|
@@ -13,6 +13,10 @@ export interface CatalogClientContributionOptions {
|
|
|
13
13
|
readonly scopes: readonly string[];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/* exports.core's own permission; the screen shows the export button only to a
|
|
17
|
+
principal that may start a job there. */
|
|
18
|
+
const EXPORTS_MANAGE = 'exports.lists.manage';
|
|
19
|
+
|
|
16
20
|
export function createCatalogClientContribution(
|
|
17
21
|
options: CatalogClientContributionOptions,
|
|
18
22
|
): ModuleClientContribution {
|
|
@@ -45,6 +49,7 @@ export function createCatalogClientContribution(
|
|
|
45
49
|
<CatalogView
|
|
46
50
|
csrfToken={options.csrfToken}
|
|
47
51
|
canManage={options.scopes.includes(CATALOG_PERMISSIONS.manage)}
|
|
52
|
+
canExport={options.scopes.includes(EXPORTS_MANAGE)}
|
|
48
53
|
/>,
|
|
49
54
|
},
|
|
50
55
|
],
|
|
@@ -1,12 +1,64 @@
|
|
|
1
1
|
import { cell, createStore } from 'segment-state';
|
|
2
2
|
import type { HistoryEntry } from '@flowdular/sdk/kernel';
|
|
3
|
-
import type {
|
|
3
|
+
import type { TableSort } from '@flowdular/sdk/ui';
|
|
4
|
+
import type {
|
|
5
|
+
CatalogBulkOutcome,
|
|
6
|
+
CatalogItem,
|
|
7
|
+
CatalogItemKind,
|
|
8
|
+
CatalogItemStatus,
|
|
9
|
+
CatalogListSort,
|
|
10
|
+
} from '../domain/types.ts';
|
|
11
|
+
import type { CatalogListPage } from './api.ts';
|
|
12
|
+
|
|
13
|
+
/** `denied` is a 403 the shell could not hide; `error` is everything else. */
|
|
14
|
+
export type CatalogStatus =
|
|
15
|
+
| 'idle'
|
|
16
|
+
| 'loading'
|
|
17
|
+
| 'submitting'
|
|
18
|
+
| 'denied'
|
|
19
|
+
| 'error';
|
|
20
|
+
|
|
21
|
+
export type CatalogKindFilter = '' | CatalogItemKind;
|
|
22
|
+
export type CatalogStatusFilter = '' | CatalogItemStatus;
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_CATALOG_SORTING: readonly TableSort[] = [
|
|
25
|
+
{ key: 'name', desc: false },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** The screen opens on the active items; archived ones are a filter away. */
|
|
29
|
+
export const DEFAULT_STATUS_FILTER: CatalogStatusFilter = 'active';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The cursor that opened each page the reader has visited: page 0 has none,
|
|
33
|
+
* and page n holds the cursor page n - 1 answered. Going back reuses the stored
|
|
34
|
+
* cursor; a page past the stack cannot be opened.
|
|
35
|
+
*/
|
|
36
|
+
export type PageCursors = readonly (string | null)[];
|
|
37
|
+
|
|
38
|
+
/** One listing as the server answers it: the page, its order and its filters. */
|
|
39
|
+
export interface Listing {
|
|
40
|
+
readonly pageIndex: number;
|
|
41
|
+
readonly pageSize: number;
|
|
42
|
+
readonly sorting: readonly TableSort[];
|
|
43
|
+
readonly query: string;
|
|
44
|
+
readonly kind: CatalogKindFilter;
|
|
45
|
+
readonly status: CatalogStatusFilter;
|
|
46
|
+
readonly cursors: PageCursors;
|
|
47
|
+
}
|
|
4
48
|
|
|
5
49
|
export function createCatalogClientState() {
|
|
6
50
|
const store = createStore({
|
|
7
51
|
items: cell<readonly CatalogItem[]>([]),
|
|
8
52
|
query: '',
|
|
9
|
-
|
|
53
|
+
/** The term the rows on screen answer; typing moves `query` ahead of it. */
|
|
54
|
+
appliedQuery: '',
|
|
55
|
+
kindFilter: cell<CatalogKindFilter>(''),
|
|
56
|
+
statusFilter: cell<CatalogStatusFilter>(DEFAULT_STATUS_FILTER),
|
|
57
|
+
sorting: cell<readonly TableSort[]>(DEFAULT_CATALOG_SORTING),
|
|
58
|
+
pageIndex: 0,
|
|
59
|
+
pageSize: 50,
|
|
60
|
+
cursors: cell<PageCursors>([null]),
|
|
61
|
+
nextCursor: cell<string | null>(null),
|
|
10
62
|
filtersOpen: false,
|
|
11
63
|
formOpen: false,
|
|
12
64
|
deleteConfirmOpen: false,
|
|
@@ -16,9 +68,123 @@ export function createCatalogClientState() {
|
|
|
16
68
|
historyEntries: cell<readonly HistoryEntry[]>([]),
|
|
17
69
|
historyLoading: false,
|
|
18
70
|
historyError: '',
|
|
19
|
-
status: cell<
|
|
71
|
+
status: cell<CatalogStatus>('idle'),
|
|
20
72
|
error: '',
|
|
21
73
|
formSession: 0,
|
|
74
|
+
/* Row ids checked in the table; the screen owns it, the table never
|
|
75
|
+
clears it. */
|
|
76
|
+
selectedIds: cell<ReadonlySet<string>>(new Set()),
|
|
77
|
+
confirmArchiveMany: false,
|
|
78
|
+
exportStarted: false,
|
|
22
79
|
});
|
|
23
80
|
return { store, state: store.state };
|
|
24
81
|
}
|
|
82
|
+
|
|
83
|
+
/** The server's sort key and direction behind the table's sorting state. */
|
|
84
|
+
export function catalogSort(sorts: readonly TableSort[]): {
|
|
85
|
+
readonly sort: CatalogListSort;
|
|
86
|
+
readonly direction: 'asc' | 'desc';
|
|
87
|
+
} {
|
|
88
|
+
const first = sorts[0] ?? DEFAULT_CATALOG_SORTING[0]!;
|
|
89
|
+
return {
|
|
90
|
+
sort: first.key === 'sku' || first.key === 'updatedAt' ? first.key : 'name',
|
|
91
|
+
direction: first.desc ? 'desc' : 'asc',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The cursor that opens `pageIndex`, or undefined when the reader never reached it. */
|
|
96
|
+
export function pageCursor(
|
|
97
|
+
cursors: PageCursors,
|
|
98
|
+
pageIndex: number,
|
|
99
|
+
): string | null | undefined {
|
|
100
|
+
return pageIndex < cursors.length ? cursors[pageIndex] : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Records what the page at `pageIndex` answered: the cursor of the page after
|
|
105
|
+
* it, or nothing when it was the last. Pages beyond the next one are dropped,
|
|
106
|
+
* since the set may have changed under them.
|
|
107
|
+
*/
|
|
108
|
+
export function rememberNextCursor(
|
|
109
|
+
cursors: PageCursors,
|
|
110
|
+
pageIndex: number,
|
|
111
|
+
nextCursor: string | null,
|
|
112
|
+
): PageCursors {
|
|
113
|
+
const kept = cursors.slice(0, pageIndex + 1);
|
|
114
|
+
return nextCursor === null ? kept : [...kept, nextCursor];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resetPageCursors(): PageCursors {
|
|
118
|
+
return [null];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What a loaded page writes to the screen. The selection is emptied with every
|
|
123
|
+
* listing, on a page, sort or filter change as much as on a refresh, because a
|
|
124
|
+
* selected id names a row the reader saw on the page that is being replaced.
|
|
125
|
+
*/
|
|
126
|
+
export function loadedListing(
|
|
127
|
+
next: Listing,
|
|
128
|
+
page: CatalogListPage,
|
|
129
|
+
): {
|
|
130
|
+
readonly items: readonly CatalogItem[];
|
|
131
|
+
readonly appliedQuery: string;
|
|
132
|
+
readonly pageIndex: number;
|
|
133
|
+
readonly cursors: PageCursors;
|
|
134
|
+
readonly nextCursor: string | null;
|
|
135
|
+
readonly selectedIds: ReadonlySet<string>;
|
|
136
|
+
} {
|
|
137
|
+
return {
|
|
138
|
+
items: page.items,
|
|
139
|
+
appliedQuery: next.query,
|
|
140
|
+
pageIndex: next.pageIndex,
|
|
141
|
+
cursors: rememberNextCursor(
|
|
142
|
+
next.cursors,
|
|
143
|
+
next.pageIndex,
|
|
144
|
+
page.page.nextCursor,
|
|
145
|
+
),
|
|
146
|
+
nextCursor: page.page.nextCursor,
|
|
147
|
+
selectedIds: new Set(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function searchPending(query: string, applied: string): boolean {
|
|
152
|
+
return query.trim() !== applied;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The selected rows a lifecycle action may name: on screen and in the status it leaves. */
|
|
156
|
+
export function bulkTargets(
|
|
157
|
+
items: readonly CatalogItem[],
|
|
158
|
+
selected: ReadonlySet<string>,
|
|
159
|
+
from: CatalogItemStatus,
|
|
160
|
+
): readonly string[] {
|
|
161
|
+
return items
|
|
162
|
+
.filter((item) => selected.has(item.id) && item.status === from)
|
|
163
|
+
.map((item) => item.id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** How many ids each outcome covered, for the toast after a bulk action. */
|
|
167
|
+
export function bulkOutcomeCounts(outcomes: readonly CatalogBulkOutcome[]): {
|
|
168
|
+
readonly updated: number;
|
|
169
|
+
readonly missing: number;
|
|
170
|
+
readonly refused: number;
|
|
171
|
+
} {
|
|
172
|
+
let updated = 0;
|
|
173
|
+
let missing = 0;
|
|
174
|
+
let refused = 0;
|
|
175
|
+
for (const entry of outcomes) {
|
|
176
|
+
if (entry.outcome === 'updated') updated += 1;
|
|
177
|
+
else if (entry.outcome === 'not-found') missing += 1;
|
|
178
|
+
else refused += 1;
|
|
179
|
+
}
|
|
180
|
+
return { updated, missing, refused };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* The server ordered the page, so a changed row keeps its place until the next
|
|
184
|
+
read; a row that is not on this page changes nothing here. */
|
|
185
|
+
export function replaceItem(
|
|
186
|
+
items: readonly CatalogItem[],
|
|
187
|
+
item: CatalogItem,
|
|
188
|
+
): readonly CatalogItem[] {
|
|
189
|
+
return items.map((current) => (current.id === item.id ? item : current));
|
|
190
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The id exports.core holds this module's items list under: the module id
|
|
3
|
+
* followed by the list key. The screen that starts a job and the server that
|
|
4
|
+
* declares the list name one string, without the client bundle importing
|
|
5
|
+
* `@flowdular/sdk/server` to read it.
|
|
6
|
+
*/
|
|
7
|
+
export const CATALOG_ITEMS_EXPORT_LIST_ID = 'catalog.core.items';
|
|
@@ -11,6 +11,26 @@ export interface CatalogItem {
|
|
|
11
11
|
readonly currency: string;
|
|
12
12
|
readonly status: 'active' | 'archived';
|
|
13
13
|
readonly createdAt: number;
|
|
14
|
+
readonly updatedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type CatalogItemStatus = CatalogItem['status'];
|
|
18
|
+
|
|
19
|
+
/** The orders the items list answers; each one ends in the item id. */
|
|
20
|
+
export const CATALOG_LIST_SORTS = ['name', 'sku', 'updatedAt'] as const;
|
|
21
|
+
export type CatalogListSort = (typeof CATALOG_LIST_SORTS)[number];
|
|
22
|
+
|
|
23
|
+
/** Characters a list search term may carry. */
|
|
24
|
+
export const CATALOG_SEARCH_LENGTH = 120;
|
|
25
|
+
|
|
26
|
+
/** Ids one bulk lifecycle call may name. */
|
|
27
|
+
export const CATALOG_BULK_LIMIT = 100;
|
|
28
|
+
|
|
29
|
+
export interface CatalogBulkOutcome {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly outcome: 'updated' | 'not-found' | 'refused';
|
|
32
|
+
/** The stable service code behind a refusal. */
|
|
33
|
+
readonly reason?: string;
|
|
14
34
|
}
|
|
15
35
|
|
|
16
36
|
export interface CreateCatalogItemInput {
|
|
@@ -2,9 +2,14 @@ import type {
|
|
|
2
2
|
PlatformServerComposition,
|
|
3
3
|
PlatformServerContext,
|
|
4
4
|
} from '@flowdular/sdk/modules/auth/server';
|
|
5
|
+
import {
|
|
6
|
+
EXPORT_LISTS_CAPABILITY,
|
|
7
|
+
type ExportLists,
|
|
8
|
+
} from '@flowdular/sdk/modules/exports';
|
|
5
9
|
import { platformVariableRegistry } from '@flowdular/sdk/kernel';
|
|
6
10
|
import { registerCatalogVariableSource } from './domain/variables.ts';
|
|
7
11
|
import { catalogDataClasses } from './services/data-classes.ts';
|
|
12
|
+
import { createCatalogItemsListExport } from './services/item-export.ts';
|
|
8
13
|
import {
|
|
9
14
|
catalogAgentTools,
|
|
10
15
|
createCatalogRoutes,
|
|
@@ -30,8 +35,23 @@ export function createServerComposition(
|
|
|
30
35
|
tools,
|
|
31
36
|
);
|
|
32
37
|
context.dataClasses.declare(catalogDataClasses(() => runtime.service()));
|
|
38
|
+
/* exports.core is an optional requirement, so its registry may not exist
|
|
39
|
+
yet while this module composes; it is asked for here and again at start,
|
|
40
|
+
and a deployment without exports.core registers nothing. */
|
|
41
|
+
let registered = false;
|
|
42
|
+
const registerLists = (): void => {
|
|
43
|
+
if (registered) return;
|
|
44
|
+
const lists = context.capabilities.get<ExportLists>(
|
|
45
|
+
EXPORT_LISTS_CAPABILITY,
|
|
46
|
+
);
|
|
47
|
+
if (!lists) return;
|
|
48
|
+
registered = true;
|
|
49
|
+
lists.register('catalog.core', [createCatalogItemsListExport(runtime)]);
|
|
50
|
+
};
|
|
51
|
+
registerLists();
|
|
33
52
|
return {
|
|
34
53
|
routes: createCatalogRoutes(context.auth, runtime),
|
|
54
|
+
start: () => registerLists(),
|
|
35
55
|
dispose: () => runtime.dispose(),
|
|
36
56
|
};
|
|
37
57
|
}
|
|
@@ -7,14 +7,22 @@ import {
|
|
|
7
7
|
type HistoryPage,
|
|
8
8
|
type HistoryRequest,
|
|
9
9
|
} from '@flowdular/sdk/kernel';
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
import {
|
|
11
|
+
CATALOG_BULK_LIMIT,
|
|
12
|
+
CATALOG_LIST_SORTS,
|
|
13
|
+
CATALOG_SEARCH_LENGTH,
|
|
14
|
+
type CatalogBulkOutcome,
|
|
15
|
+
type CatalogItem,
|
|
16
|
+
type CatalogItemKind,
|
|
17
|
+
type CatalogItemStatus,
|
|
18
|
+
type CatalogListSort,
|
|
19
|
+
type CreateCatalogItemInput,
|
|
20
|
+
type UpdateCatalogItemInput,
|
|
15
21
|
} from '../domain/types.ts';
|
|
16
22
|
import {
|
|
17
23
|
DuplicateSkuError,
|
|
24
|
+
type CatalogListPage,
|
|
25
|
+
type CatalogPageKeyset,
|
|
18
26
|
type CatalogRepository,
|
|
19
27
|
type ExportCursor,
|
|
20
28
|
} from './repository.ts';
|
|
@@ -31,6 +39,32 @@ export interface CatalogIdempotencyRequest {
|
|
|
31
39
|
/** Rows one export read carries; the whole tenant is walked page by page. */
|
|
32
40
|
export const EXPORT_PAGE = 200;
|
|
33
41
|
|
|
42
|
+
/** The default page of the items list, and the most a read may ask for. */
|
|
43
|
+
export const LIST_PAGE_LIMIT = 50;
|
|
44
|
+
export const LIST_PAGE_MAX_LIMIT = 200;
|
|
45
|
+
|
|
46
|
+
/** One list page as a caller asks for it, before the service validates it. */
|
|
47
|
+
export interface CatalogListInput {
|
|
48
|
+
readonly sort: CatalogListSort;
|
|
49
|
+
readonly direction: 'asc' | 'desc';
|
|
50
|
+
readonly kind: CatalogItemKind | null;
|
|
51
|
+
readonly status: CatalogItemStatus | null;
|
|
52
|
+
/** A substring of the name or the SKU; '' narrows nothing. */
|
|
53
|
+
readonly search: string;
|
|
54
|
+
readonly limit: number;
|
|
55
|
+
readonly after: CatalogPageKeyset | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const FIRST_LIST_PAGE: CatalogListInput = {
|
|
59
|
+
sort: 'name',
|
|
60
|
+
direction: 'asc',
|
|
61
|
+
kind: null,
|
|
62
|
+
status: null,
|
|
63
|
+
search: '',
|
|
64
|
+
limit: LIST_PAGE_MAX_LIMIT,
|
|
65
|
+
after: null,
|
|
66
|
+
};
|
|
67
|
+
|
|
34
68
|
export class CatalogServiceError extends Error {
|
|
35
69
|
constructor(
|
|
36
70
|
readonly code: string,
|
|
@@ -68,6 +102,16 @@ function itemKind(value: CatalogItemKind): CatalogItemKind {
|
|
|
68
102
|
return value;
|
|
69
103
|
}
|
|
70
104
|
|
|
105
|
+
function itemStatus(value: CatalogItemStatus): CatalogItemStatus {
|
|
106
|
+
if (value !== 'active' && value !== 'archived') {
|
|
107
|
+
throw new CatalogServiceError(
|
|
108
|
+
'INVALID_INPUT',
|
|
109
|
+
'status must be active or archived.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
71
115
|
function trustedActor(actor: Actor): Actor {
|
|
72
116
|
const normalized = normalizeActor(actor);
|
|
73
117
|
if (!normalized) {
|
|
@@ -82,8 +126,52 @@ function trustedActor(actor: Actor): Actor {
|
|
|
82
126
|
export class CatalogService {
|
|
83
127
|
constructor(private readonly repository: CatalogRepository) {}
|
|
84
128
|
|
|
85
|
-
|
|
86
|
-
|
|
129
|
+
/** One server-ordered, server-narrowed page; the only listing the module answers. */
|
|
130
|
+
async listPage(
|
|
131
|
+
tenantId: string,
|
|
132
|
+
input: CatalogListInput,
|
|
133
|
+
): Promise<CatalogListPage> {
|
|
134
|
+
if (!(CATALOG_LIST_SORTS as readonly string[]).includes(input.sort)) {
|
|
135
|
+
throw new CatalogServiceError(
|
|
136
|
+
'INVALID_INPUT',
|
|
137
|
+
`sort must be one of ${CATALOG_LIST_SORTS.join(', ')}.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (input.direction !== 'asc' && input.direction !== 'desc') {
|
|
141
|
+
throw new CatalogServiceError(
|
|
142
|
+
'INVALID_INPUT',
|
|
143
|
+
'direction must be asc or desc.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (
|
|
147
|
+
!Number.isSafeInteger(input.limit) ||
|
|
148
|
+
input.limit < 1 ||
|
|
149
|
+
input.limit > LIST_PAGE_MAX_LIMIT
|
|
150
|
+
) {
|
|
151
|
+
throw new CatalogServiceError(
|
|
152
|
+
'INVALID_INPUT',
|
|
153
|
+
`limit must be an integer between 1 and ${LIST_PAGE_MAX_LIMIT}.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const search = bounded(input.search, 'search', 0, CATALOG_SEARCH_LENGTH);
|
|
157
|
+
return await this.repository.listPage(
|
|
158
|
+
bounded(tenantId, 'tenantId', 1, 128),
|
|
159
|
+
{
|
|
160
|
+
sort: input.sort,
|
|
161
|
+
direction: input.direction,
|
|
162
|
+
kind: input.kind === null ? null : itemKind(input.kind),
|
|
163
|
+
status: input.status === null ? null : itemStatus(input.status),
|
|
164
|
+
term: search === '' ? null : search.toLocaleLowerCase('en-US'),
|
|
165
|
+
limit: input.limit,
|
|
166
|
+
after:
|
|
167
|
+
input.after === null
|
|
168
|
+
? null
|
|
169
|
+
: {
|
|
170
|
+
sortValue: bounded(input.after.sortValue, 'cursor', 0, 512),
|
|
171
|
+
id: bounded(input.after.id, 'cursor', 1, 128),
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
);
|
|
87
175
|
}
|
|
88
176
|
|
|
89
177
|
async get(tenantId: string, id: string): Promise<CatalogItem | null> {
|
|
@@ -160,6 +248,7 @@ export class CatalogService {
|
|
|
160
248
|
'currency must be a three-letter ISO code.',
|
|
161
249
|
);
|
|
162
250
|
}
|
|
251
|
+
const now = Date.now();
|
|
163
252
|
return {
|
|
164
253
|
id: randomUUID(),
|
|
165
254
|
tenantId: bounded(tenantId, 'tenantId', 1, 128),
|
|
@@ -170,7 +259,8 @@ export class CatalogService {
|
|
|
170
259
|
basePriceMinor: input.basePriceMinor,
|
|
171
260
|
currency,
|
|
172
261
|
status: 'active',
|
|
173
|
-
createdAt:
|
|
262
|
+
createdAt: now,
|
|
263
|
+
updatedAt: now,
|
|
174
264
|
};
|
|
175
265
|
}
|
|
176
266
|
|
|
@@ -246,6 +336,51 @@ export class CatalogService {
|
|
|
246
336
|
return await this.changeStatus(tenantId, id, 'active', actor);
|
|
247
337
|
}
|
|
248
338
|
|
|
339
|
+
archiveMany(
|
|
340
|
+
tenantId: string,
|
|
341
|
+
ids: readonly string[],
|
|
342
|
+
actor: Actor,
|
|
343
|
+
): Promise<readonly CatalogBulkOutcome[]> {
|
|
344
|
+
return this.each(ids, (id) => this.archive(tenantId, id, actor));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
restoreMany(
|
|
348
|
+
tenantId: string,
|
|
349
|
+
ids: readonly string[],
|
|
350
|
+
actor: Actor,
|
|
351
|
+
): Promise<readonly CatalogBulkOutcome[]> {
|
|
352
|
+
return this.each(ids, (id) => this.restore(tenantId, id, actor));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* One outcome per id through the single-row path, so every accepted
|
|
356
|
+
transition keeps its own history row and a refused id fails no other. */
|
|
357
|
+
private async each(
|
|
358
|
+
ids: readonly string[],
|
|
359
|
+
write: (id: string) => Promise<unknown>,
|
|
360
|
+
): Promise<readonly CatalogBulkOutcome[]> {
|
|
361
|
+
if (ids.length < 1 || ids.length > CATALOG_BULK_LIMIT) {
|
|
362
|
+
throw new CatalogServiceError(
|
|
363
|
+
'INVALID_INPUT',
|
|
364
|
+
`ids must name between 1 and ${CATALOG_BULK_LIMIT} items.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const outcomes: CatalogBulkOutcome[] = [];
|
|
368
|
+
for (const id of ids) {
|
|
369
|
+
try {
|
|
370
|
+
await write(id);
|
|
371
|
+
outcomes.push({ id, outcome: 'updated' });
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (!(error instanceof CatalogServiceError)) throw error;
|
|
374
|
+
outcomes.push(
|
|
375
|
+
error.code === 'CATALOG_ITEM_NOT_FOUND'
|
|
376
|
+
? { id, outcome: 'not-found' }
|
|
377
|
+
: { id, outcome: 'refused', reason: error.code },
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return outcomes;
|
|
382
|
+
}
|
|
383
|
+
|
|
249
384
|
async delete(tenantId: string, id: string, actor: Actor): Promise<void> {
|
|
250
385
|
const trustedTenantId = bounded(tenantId, 'tenantId', 1, 128);
|
|
251
386
|
const trustedId = bounded(id, 'id', 1, 128);
|