@saasicat/ui-vue 0.26.1 → 0.27.0
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/LICENSE +90 -201
- package/dist/{catalog-Dch1Ryw0.d.cts → catalog-Dv2twuNi.d.cts} +205 -4
- package/dist/{catalog-Dch1Ryw0.d.ts → catalog-Dv2twuNi.d.ts} +205 -4
- package/dist/{chunk-COVQLDBZ.js → chunk-4IZWT4UY.js} +402 -1
- package/dist/{chunk-DT25QAU2.js → chunk-LK6YJDXC.js} +1 -1
- package/dist/{chunk-O3Y3BYYS.js → chunk-NRVF3JJJ.js} +1 -1
- package/dist/client/index.cjs +413 -3
- package/dist/client/index.d.cts +55 -4
- package/dist/client/index.d.ts +55 -4
- package/dist/client/index.js +22 -4
- package/dist/index.cjs +416 -6
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +23 -5
- package/dist/quasar/index.cjs +399 -1
- package/dist/quasar/index.d.cts +2 -2
- package/dist/quasar/index.d.ts +2 -2
- package/dist/quasar/index.js +2 -2
- package/dist/{resource-registry-D7Xjs-5f.d.cts → resource-registry-BxFpnzHY.d.cts} +1 -1
- package/dist/{resource-registry-TaR7rtq0.d.ts → resource-registry-Cn1Gy2LE.d.ts} +1 -1
- package/package.json +3 -3
- package/src/client/resources/bundles.resource.ts +145 -0
- package/src/client/resources/catalog.resource.ts +160 -0
- package/src/client/resources/discovery.resource.ts +108 -0
- package/src/client/resources/index.ts +52 -14
- package/src/client/resources/marketing.resource.ts +121 -0
- package/src/client/resources/promo-codes.resource.ts +63 -0
- package/src/client/resources/promotions.resource.ts +44 -0
- package/src/client/resources/subscriptions.resource.ts +19 -0
- package/src/client/resources/tenants.resource.ts +35 -9
- package/src/client/resources/users.resource.ts +31 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The bundle catalogue.
|
|
2
|
+
//
|
|
3
|
+
// Same two-stem shape as the plan catalogue, and for the same reason: a
|
|
4
|
+
// version is read and created through its bundle
|
|
5
|
+
// (`catalog/bundles/:bundleId/versions`), while every mutation of an existing
|
|
6
|
+
// version addresses the version directly (`catalog/bundle-versions/:id`).
|
|
7
|
+
//
|
|
8
|
+
// Path parameters are interpolated without encoding, matching `useBundles` and
|
|
9
|
+
// `useBundleVersions`. Encoding them would send ids containing `/`, `#` or `?`
|
|
10
|
+
// to a different URL than they reach today — a behaviour change, and one that
|
|
11
|
+
// belongs in a change that says so. The same note stands on `plans.resource`.
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
BundleRow,
|
|
15
|
+
BundleVersionMutationResult,
|
|
16
|
+
BundleVersionRow,
|
|
17
|
+
CreateBundleData,
|
|
18
|
+
CreateBundleVersionDraftData,
|
|
19
|
+
UpdateBundleData,
|
|
20
|
+
UpdateBundleVersionDraftData,
|
|
21
|
+
} from '@saasicat/types';
|
|
22
|
+
|
|
23
|
+
import { defineResource, type ResourceContext } from './define-resource.js';
|
|
24
|
+
import { requestJson, requestJsonBody } from './resource-request.js';
|
|
25
|
+
|
|
26
|
+
/** What `publish` may override at the moment a bundle version goes live. */
|
|
27
|
+
export interface PublishBundleVersionOptions {
|
|
28
|
+
forceRegressive?: boolean;
|
|
29
|
+
allowZeroPrice?: boolean;
|
|
30
|
+
validFrom?: string | null;
|
|
31
|
+
validUntil?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bundlesUrl(ctx: ResourceContext): string {
|
|
35
|
+
return `${ctx.apiBase}/catalog/bundles`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function bundleVersionsUrl(ctx: ResourceContext): string {
|
|
39
|
+
return `${ctx.apiBase}/catalog/bundle-versions`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `?projectKey=…` — the key is a literal, only the value is encoded. */
|
|
43
|
+
function scoped(url: string, ctx: ResourceContext): string {
|
|
44
|
+
return `${url}?projectKey=${encodeURIComponent(ctx.projectKey)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The bundle catalogue — read per project, like the plan catalogue.
|
|
49
|
+
*
|
|
50
|
+
* `create` takes the project from the context rather than from the caller, for
|
|
51
|
+
* the reason spelled out on `plansResource.create`: the composable this
|
|
52
|
+
* replaces made it the caller's job, so `list()` and `create()` read two
|
|
53
|
+
* different sources for one value and a stale one creates a bundle outside the
|
|
54
|
+
* project the list is showing.
|
|
55
|
+
*/
|
|
56
|
+
export const bundlesResource = defineResource(
|
|
57
|
+
'bundles',
|
|
58
|
+
{
|
|
59
|
+
list: async (http, ctx): Promise<BundleRow[]> =>
|
|
60
|
+
(await requestJson<BundleRow[]>(http, scoped(bundlesUrl(ctx), ctx))) ?? [],
|
|
61
|
+
|
|
62
|
+
create: (http, ctx, data: Omit<CreateBundleData, 'projectKey'>): Promise<BundleRow> =>
|
|
63
|
+
requestJsonBody<BundleRow>(http, bundlesUrl(ctx), 'Create returned no body', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
body: { ...data, projectKey: ctx.projectKey },
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
update: (http, ctx, bundleId: string, data: UpdateBundleData): Promise<BundleRow> =>
|
|
69
|
+
requestJsonBody<BundleRow>(
|
|
70
|
+
http,
|
|
71
|
+
`${bundlesUrl(ctx)}/${bundleId}`,
|
|
72
|
+
'Update returned no body',
|
|
73
|
+
{ method: 'PATCH', body: data },
|
|
74
|
+
),
|
|
75
|
+
|
|
76
|
+
/** Marks the bundle deleted. */
|
|
77
|
+
softDelete: async (http, ctx, bundleId: string): Promise<void> => {
|
|
78
|
+
await requestJson(http, `${bundlesUrl(ctx)}/${bundleId}`, { method: 'DELETE' });
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{ projectScoped: true },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Bundle version lifecycle.
|
|
86
|
+
*
|
|
87
|
+
* `publish` is the request only. `useBundleVersions.publish` reloads the list
|
|
88
|
+
* afterwards, because publishing may supersede another version — but that is
|
|
89
|
+
* the composable keeping its own refs consistent, not part of the call. A
|
|
90
|
+
* descriptor operation that issued a second request would make every override
|
|
91
|
+
* inherit a reload it cannot see, and a page holding no list would pay for one
|
|
92
|
+
* it does not read.
|
|
93
|
+
*/
|
|
94
|
+
export const bundleVersionsResource = defineResource('bundleVersions', {
|
|
95
|
+
listForBundle: async (http, ctx, bundleId: string): Promise<BundleVersionRow[]> =>
|
|
96
|
+
(await requestJson<BundleVersionRow[]>(http, `${bundlesUrl(ctx)}/${bundleId}/versions`)) ??
|
|
97
|
+
[],
|
|
98
|
+
|
|
99
|
+
createDraft: (
|
|
100
|
+
http,
|
|
101
|
+
ctx,
|
|
102
|
+
bundleId: string,
|
|
103
|
+
data: Omit<CreateBundleVersionDraftData, 'bundleId'>,
|
|
104
|
+
): Promise<BundleVersionMutationResult> =>
|
|
105
|
+
requestJsonBody<BundleVersionMutationResult>(
|
|
106
|
+
http,
|
|
107
|
+
`${bundlesUrl(ctx)}/${bundleId}/versions`,
|
|
108
|
+
'CreateDraft returned no body',
|
|
109
|
+
{ method: 'POST', body: data },
|
|
110
|
+
),
|
|
111
|
+
|
|
112
|
+
updateDraft: (
|
|
113
|
+
http,
|
|
114
|
+
ctx,
|
|
115
|
+
versionId: string,
|
|
116
|
+
data: UpdateBundleVersionDraftData,
|
|
117
|
+
): Promise<BundleVersionMutationResult> =>
|
|
118
|
+
requestJsonBody<BundleVersionMutationResult>(
|
|
119
|
+
http,
|
|
120
|
+
`${bundleVersionsUrl(ctx)}/${versionId}`,
|
|
121
|
+
'UpdateDraft returned no body',
|
|
122
|
+
{ method: 'PATCH', body: data },
|
|
123
|
+
),
|
|
124
|
+
|
|
125
|
+
publish: (
|
|
126
|
+
http,
|
|
127
|
+
ctx,
|
|
128
|
+
versionId: string,
|
|
129
|
+
options: PublishBundleVersionOptions = {},
|
|
130
|
+
): Promise<BundleVersionMutationResult> =>
|
|
131
|
+
requestJsonBody<BundleVersionMutationResult>(
|
|
132
|
+
http,
|
|
133
|
+
`${bundleVersionsUrl(ctx)}/${versionId}/publish`,
|
|
134
|
+
'Publish returned no body',
|
|
135
|
+
{ method: 'POST', body: options },
|
|
136
|
+
),
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Discards a draft. A published version cannot be discarded — the API
|
|
140
|
+
* answers 422 `BUNDLE_VERSION_ALREADY_PUBLISHED`.
|
|
141
|
+
*/
|
|
142
|
+
discardDraft: async (http, ctx, versionId: string): Promise<void> => {
|
|
143
|
+
await requestJson(http, `${bundleVersionsUrl(ctx)}/${versionId}`, { method: 'DELETE' });
|
|
144
|
+
},
|
|
145
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The capability / feature / quota catalogue.
|
|
2
|
+
//
|
|
3
|
+
// Every operation but one carries `?projectKey=…`, including the mutations —
|
|
4
|
+
// the keys are unique per project, so the server needs it to address the row.
|
|
5
|
+
// The exception is `syncDiscovery`, whose payload carries the snapshot and
|
|
6
|
+
// whose endpoint takes no query; that asymmetry is the server's.
|
|
7
|
+
//
|
|
8
|
+
// `load()` in `useCatalogEntries` fetches the three lists in one `Promise.all`
|
|
9
|
+
// and writes three refs. Here they are three operations: the descriptor's job
|
|
10
|
+
// is the request, and which of them a caller needs — a page showing only
|
|
11
|
+
// quotas asks for one — is the caller's to decide.
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
CapabilityCatalogEntryRow,
|
|
15
|
+
CatalogEntryI18n,
|
|
16
|
+
DiscoverySnapshot,
|
|
17
|
+
FeatureCatalogEntryRow,
|
|
18
|
+
QuotaCatalogEntryRow,
|
|
19
|
+
ReviewCatalogEntryData,
|
|
20
|
+
SyncDiscoveryResult,
|
|
21
|
+
UpdateCatalogEntryBaseData,
|
|
22
|
+
} from '@saasicat/types';
|
|
23
|
+
|
|
24
|
+
import { defineResource, type ResourceContext } from './define-resource.js';
|
|
25
|
+
import { requestJson, requestJsonBody } from './resource-request.js';
|
|
26
|
+
|
|
27
|
+
function catalogUrl(ctx: ResourceContext): string {
|
|
28
|
+
return `${ctx.apiBase}/catalog`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `?projectKey=…` — the key is a literal, only the value is encoded. */
|
|
32
|
+
function scoped(url: string, ctx: ResourceContext): string {
|
|
33
|
+
return `${url}?projectKey=${encodeURIComponent(ctx.projectKey)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The discovered catalogue and the review decisions taken on it.
|
|
38
|
+
*
|
|
39
|
+
* Entry keys are encoded on the way into the path — that is what
|
|
40
|
+
* `useCatalogEntries` does, and a feature key legitimately contains characters
|
|
41
|
+
* (`.`, `:`) that a code scan produces.
|
|
42
|
+
*/
|
|
43
|
+
export const catalogResource = defineResource(
|
|
44
|
+
'catalog',
|
|
45
|
+
{
|
|
46
|
+
capabilities: async (http, ctx): Promise<CapabilityCatalogEntryRow[]> =>
|
|
47
|
+
(await requestJson<CapabilityCatalogEntryRow[]>(
|
|
48
|
+
http,
|
|
49
|
+
scoped(`${catalogUrl(ctx)}/capabilities`, ctx),
|
|
50
|
+
)) ?? [],
|
|
51
|
+
|
|
52
|
+
features: async (http, ctx): Promise<FeatureCatalogEntryRow[]> =>
|
|
53
|
+
(await requestJson<FeatureCatalogEntryRow[]>(
|
|
54
|
+
http,
|
|
55
|
+
scoped(`${catalogUrl(ctx)}/features`, ctx),
|
|
56
|
+
)) ?? [],
|
|
57
|
+
|
|
58
|
+
quotas: async (http, ctx): Promise<QuotaCatalogEntryRow[]> =>
|
|
59
|
+
(await requestJson<QuotaCatalogEntryRow[]>(
|
|
60
|
+
http,
|
|
61
|
+
scoped(`${catalogUrl(ctx)}/quotas`, ctx),
|
|
62
|
+
)) ?? [],
|
|
63
|
+
|
|
64
|
+
reviewFeature: (
|
|
65
|
+
http,
|
|
66
|
+
ctx,
|
|
67
|
+
featureKey: string,
|
|
68
|
+
data: ReviewCatalogEntryData,
|
|
69
|
+
): Promise<FeatureCatalogEntryRow> =>
|
|
70
|
+
requestJsonBody<FeatureCatalogEntryRow>(
|
|
71
|
+
http,
|
|
72
|
+
scoped(`${catalogUrl(ctx)}/features/${encodeURIComponent(featureKey)}/review`, ctx),
|
|
73
|
+
'Review returned no body',
|
|
74
|
+
{ method: 'PATCH', body: data },
|
|
75
|
+
),
|
|
76
|
+
|
|
77
|
+
reviewQuota: (
|
|
78
|
+
http,
|
|
79
|
+
ctx,
|
|
80
|
+
quotaKey: string,
|
|
81
|
+
data: ReviewCatalogEntryData,
|
|
82
|
+
): Promise<QuotaCatalogEntryRow> =>
|
|
83
|
+
requestJsonBody<QuotaCatalogEntryRow>(
|
|
84
|
+
http,
|
|
85
|
+
scoped(`${catalogUrl(ctx)}/quotas/${encodeURIComponent(quotaKey)}/review`, ctx),
|
|
86
|
+
'Review returned no body',
|
|
87
|
+
{ method: 'PATCH', body: data },
|
|
88
|
+
),
|
|
89
|
+
|
|
90
|
+
/** The translations are wrapped in `{ i18n }` here, not by the caller. */
|
|
91
|
+
setFeatureI18n: (
|
|
92
|
+
http,
|
|
93
|
+
ctx,
|
|
94
|
+
featureKey: string,
|
|
95
|
+
i18n: CatalogEntryI18n,
|
|
96
|
+
): Promise<FeatureCatalogEntryRow> =>
|
|
97
|
+
requestJsonBody<FeatureCatalogEntryRow>(
|
|
98
|
+
http,
|
|
99
|
+
scoped(`${catalogUrl(ctx)}/features/${encodeURIComponent(featureKey)}/i18n`, ctx),
|
|
100
|
+
'i18n returned no body',
|
|
101
|
+
{ method: 'PATCH', body: { i18n } },
|
|
102
|
+
),
|
|
103
|
+
|
|
104
|
+
setQuotaI18n: (
|
|
105
|
+
http,
|
|
106
|
+
ctx,
|
|
107
|
+
quotaKey: string,
|
|
108
|
+
i18n: CatalogEntryI18n,
|
|
109
|
+
): Promise<QuotaCatalogEntryRow> =>
|
|
110
|
+
requestJsonBody<QuotaCatalogEntryRow>(
|
|
111
|
+
http,
|
|
112
|
+
scoped(`${catalogUrl(ctx)}/quotas/${encodeURIComponent(quotaKey)}/i18n`, ctx),
|
|
113
|
+
'i18n returned no body',
|
|
114
|
+
{ method: 'PATCH', body: { i18n } },
|
|
115
|
+
),
|
|
116
|
+
|
|
117
|
+
setFeatureBase: (
|
|
118
|
+
http,
|
|
119
|
+
ctx,
|
|
120
|
+
featureKey: string,
|
|
121
|
+
data: UpdateCatalogEntryBaseData,
|
|
122
|
+
): Promise<FeatureCatalogEntryRow> =>
|
|
123
|
+
requestJsonBody<FeatureCatalogEntryRow>(
|
|
124
|
+
http,
|
|
125
|
+
scoped(`${catalogUrl(ctx)}/features/${encodeURIComponent(featureKey)}`, ctx),
|
|
126
|
+
'Base returned no body',
|
|
127
|
+
{ method: 'PATCH', body: data },
|
|
128
|
+
),
|
|
129
|
+
|
|
130
|
+
setQuotaBase: (
|
|
131
|
+
http,
|
|
132
|
+
ctx,
|
|
133
|
+
quotaKey: string,
|
|
134
|
+
data: UpdateCatalogEntryBaseData,
|
|
135
|
+
): Promise<QuotaCatalogEntryRow> =>
|
|
136
|
+
requestJsonBody<QuotaCatalogEntryRow>(
|
|
137
|
+
http,
|
|
138
|
+
scoped(`${catalogUrl(ctx)}/quotas/${encodeURIComponent(quotaKey)}`, ctx),
|
|
139
|
+
'Base returned no body',
|
|
140
|
+
{ method: 'PATCH', body: data },
|
|
141
|
+
),
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Writes a discovery snapshot into the catalogue.
|
|
145
|
+
*
|
|
146
|
+
* The request only. `useCatalogEntries.syncDiscovery` reloads the three
|
|
147
|
+
* lists afterwards, which is that composable keeping its refs true —
|
|
148
|
+
* not part of the call, and not something an override should inherit
|
|
149
|
+
* invisibly.
|
|
150
|
+
*/
|
|
151
|
+
syncDiscovery: (http, ctx, snapshot: DiscoverySnapshot): Promise<SyncDiscoveryResult> =>
|
|
152
|
+
requestJsonBody<SyncDiscoveryResult>(
|
|
153
|
+
http,
|
|
154
|
+
`${catalogUrl(ctx)}/discovery/sync`,
|
|
155
|
+
'Sync returned no body',
|
|
156
|
+
{ method: 'POST', body: { snapshot } },
|
|
157
|
+
),
|
|
158
|
+
},
|
|
159
|
+
{ projectScoped: true },
|
|
160
|
+
);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The discovery snapshot — what a code scan found, before anyone reviewed it.
|
|
2
|
+
//
|
|
3
|
+
// The one family that does not go through `requestJson`, and the reason is the
|
|
4
|
+
// ETag. `requestJson` collapses "204", "unparsable 2xx" and — since 304 is
|
|
5
|
+
// neither `>= 400` nor 204 — "not modified" into the same `null`, which is
|
|
6
|
+
// right for the catalogue family and wrong here: a caller that cannot tell a
|
|
7
|
+
// cache hit from an empty answer either re-parses a body it does not have or
|
|
8
|
+
// throws away the snapshot it already holds.
|
|
9
|
+
//
|
|
10
|
+
// So these two operations read the status themselves and answer with a
|
|
11
|
+
// discriminated result. The conditional request stays the caller's decision —
|
|
12
|
+
// the descriptor is given the tag it should revalidate against, rather than
|
|
13
|
+
// holding one, because a descriptor is bound once and shared by every page
|
|
14
|
+
// that asks for it.
|
|
15
|
+
|
|
16
|
+
import { AdminError } from '../admin-error.js';
|
|
17
|
+
import { requireServerAnswer } from '../http-json.js';
|
|
18
|
+
import type { DiscoverySnapshot } from '@saasicat/types';
|
|
19
|
+
|
|
20
|
+
import { defineResource, type ResourceContext } from './define-resource.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The answer to a conditional read.
|
|
24
|
+
*
|
|
25
|
+
* `unchanged` carries no snapshot on purpose: the server said the caller's copy
|
|
26
|
+
* is current, and returning a re-fetched one would defeat the request.
|
|
27
|
+
*/
|
|
28
|
+
export type DiscoveryRead =
|
|
29
|
+
| { readonly status: 'unchanged' }
|
|
30
|
+
| {
|
|
31
|
+
readonly status: 'loaded';
|
|
32
|
+
readonly snapshot: DiscoverySnapshot;
|
|
33
|
+
/** `null` when the server sent none — revalidation is then a full read. */
|
|
34
|
+
readonly etag: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function discoveryUrl(ctx: ResourceContext): string {
|
|
38
|
+
return `${ctx.apiBase}/discovery`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fail(status: number, method: string, url: string, message: string): AdminError {
|
|
42
|
+
return new AdminError({ status, url, method, message });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const discoveryResource = defineResource('discovery', {
|
|
46
|
+
/**
|
|
47
|
+
* Reads the snapshot, revalidating against `etag` when one is passed.
|
|
48
|
+
*
|
|
49
|
+
* Pass `null` (or nothing) to force a full read — that is what
|
|
50
|
+
* `useDiscovery.reload()` does when the operator asks for fresh data.
|
|
51
|
+
*/
|
|
52
|
+
read: async (http, ctx, etag: string | null = null): Promise<DiscoveryRead> => {
|
|
53
|
+
const url = discoveryUrl(ctx);
|
|
54
|
+
const headers: Record<string, string> = {};
|
|
55
|
+
if (etag) headers['If-None-Match'] = etag;
|
|
56
|
+
|
|
57
|
+
const response = await http(url, { method: 'GET', headers });
|
|
58
|
+
requireServerAnswer(response.status, 'GET', url, (diagnostic) =>
|
|
59
|
+
fail(response.status, 'GET', url, diagnostic),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (response.status === 304) return { status: 'unchanged' };
|
|
63
|
+
if (response.status !== 200) {
|
|
64
|
+
throw fail(
|
|
65
|
+
response.status,
|
|
66
|
+
'GET',
|
|
67
|
+
url,
|
|
68
|
+
`Discovery endpoint responded with HTTP ${response.status}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
status: 'loaded',
|
|
73
|
+
snapshot: (await response.json()) as DiscoverySnapshot,
|
|
74
|
+
etag: response.headers.get('ETag'),
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Re-runs the scan and returns what it found.
|
|
80
|
+
*
|
|
81
|
+
* Unconditional by construction: a rescan asks the server to look again,
|
|
82
|
+
* so revalidating against a tag the previous scan produced would answer
|
|
83
|
+
* with the snapshot the rescan was meant to replace.
|
|
84
|
+
*/
|
|
85
|
+
rescan: async (http, ctx): Promise<{ snapshot: DiscoverySnapshot; etag: string | null }> => {
|
|
86
|
+
const url = `${discoveryUrl(ctx)}/rescan`;
|
|
87
|
+
const response = await http(url, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'content-type': 'application/json' },
|
|
90
|
+
});
|
|
91
|
+
requireServerAnswer(response.status, 'POST', url, (diagnostic) =>
|
|
92
|
+
fail(response.status, 'POST', url, diagnostic),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (response.status !== 200 && response.status !== 201) {
|
|
96
|
+
throw fail(
|
|
97
|
+
response.status,
|
|
98
|
+
'POST',
|
|
99
|
+
url,
|
|
100
|
+
`Discovery rescan responded with HTTP ${response.status}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
snapshot: (await response.json()) as DiscoverySnapshot,
|
|
105
|
+
etag: response.headers.get('ETag'),
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
});
|
|
@@ -1,41 +1,79 @@
|
|
|
1
1
|
// The resource layer: every admin endpoint defined once, framework-free.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// nothing keeping it honest.
|
|
3
|
+
// The roster is complete for what the platform serves. Three names from the
|
|
4
|
+
// original plan are deliberately absent, and each is absent for a reason a
|
|
5
|
+
// grep can confirm rather than a decision taken here:
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// - `pilots` — no platform controller serves `/admin/pilots`, and none is
|
|
8
|
+
// planned. The endpoints exist in one consumer's own backend
|
|
9
|
+
// (`vereinsfux/apps/api`), which is why `PilotsPage` takes them as props.
|
|
10
|
+
// A platform default would be an invented API that happens to match one
|
|
11
|
+
// app and breaks the next.
|
|
12
|
+
// - `email` — the same, for `/admin/platform-email/*`.
|
|
13
|
+
// - `dashboard` — the platform does serve `/admin/stats/dashboard`, but
|
|
14
|
+
// `DashboardPage` never asks for it: every KPI card fetches the `endpoint`
|
|
15
|
+
// its manifest entry declares. A descriptor with a hardcoded path would
|
|
16
|
+
// contradict `KpiCardDef.endpoint`, and nothing would call it.
|
|
11
17
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// `
|
|
16
|
-
//
|
|
18
|
+
// What is here instead is one descriptor per family the platform owns, plus
|
|
19
|
+
// the split the sources forced: the plan's single `promos` turned out to be
|
|
20
|
+
// two unrelated resources — `promoCodes` (`/admin/promo-codes`, a string a
|
|
21
|
+
// customer types) and `promotions` (`/admin/catalog/promotions`, a price rule
|
|
22
|
+
// on a catalogue entry). They share four operation names and nothing else.
|
|
23
|
+
//
|
|
24
|
+
// Every descriptor is measured against the implementation it mirrors:
|
|
25
|
+
// `tests/resources-match-the-composables.test.js` drives both sides with the
|
|
26
|
+
// same arguments and compares what reaches the client, in either direction. A
|
|
27
|
+
// descriptor with no such partner is not added — there would be nothing
|
|
28
|
+
// keeping it honest.
|
|
17
29
|
|
|
18
30
|
export * from './define-resource.js';
|
|
19
31
|
export * from './resource-request.js';
|
|
20
32
|
export * from './list-resource.js';
|
|
21
33
|
export * from './plans.resource.js';
|
|
34
|
+
export * from './bundles.resource.js';
|
|
35
|
+
export * from './catalog.resource.js';
|
|
36
|
+
export * from './discovery.resource.js';
|
|
37
|
+
export * from './marketing.resource.js';
|
|
38
|
+
export * from './promo-codes.resource.js';
|
|
39
|
+
export * from './promotions.resource.js';
|
|
22
40
|
export * from './tenants.resource.js';
|
|
41
|
+
export * from './users.resource.js';
|
|
42
|
+
export * from './subscriptions.resource.js';
|
|
23
43
|
export * from './audit.resource.js';
|
|
24
44
|
|
|
25
45
|
import { auditResource } from './audit.resource.js';
|
|
46
|
+
import { bundleVersionsResource, bundlesResource } from './bundles.resource.js';
|
|
47
|
+
import { catalogResource } from './catalog.resource.js';
|
|
48
|
+
import { discoveryResource } from './discovery.resource.js';
|
|
49
|
+
import { marketingResource } from './marketing.resource.js';
|
|
26
50
|
import { planVersionsResource, plansResource } from './plans.resource.js';
|
|
51
|
+
import { promoCodesResource } from './promo-codes.resource.js';
|
|
52
|
+
import { promotionsResource } from './promotions.resource.js';
|
|
53
|
+
import { subscriptionsResource } from './subscriptions.resource.js';
|
|
27
54
|
import { tenantsResource } from './tenants.resource.js';
|
|
55
|
+
import { usersResource } from './users.resource.js';
|
|
28
56
|
|
|
29
57
|
/**
|
|
30
58
|
* Every resource the shell offers by default.
|
|
31
59
|
*
|
|
32
|
-
* The registry binds these; an app overrides by key.
|
|
33
|
-
*
|
|
60
|
+
* The registry binds these; an app overrides by key. A page whose data is
|
|
61
|
+
* app-owned — pilots, platform e-mail — keeps its props, because there is no
|
|
62
|
+
* platform default to override.
|
|
34
63
|
*/
|
|
35
64
|
export const platformResources = {
|
|
36
65
|
plans: plansResource,
|
|
37
66
|
planVersions: planVersionsResource,
|
|
67
|
+
bundles: bundlesResource,
|
|
68
|
+
bundleVersions: bundleVersionsResource,
|
|
69
|
+
catalog: catalogResource,
|
|
70
|
+
discovery: discoveryResource,
|
|
71
|
+
marketing: marketingResource,
|
|
72
|
+
promoCodes: promoCodesResource,
|
|
73
|
+
promotions: promotionsResource,
|
|
38
74
|
tenants: tenantsResource,
|
|
75
|
+
users: usersResource,
|
|
76
|
+
subscriptions: subscriptionsResource,
|
|
39
77
|
audit: auditResource,
|
|
40
78
|
};
|
|
41
79
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Marketing projections and the locale settings that go with them.
|
|
2
|
+
//
|
|
3
|
+
// Two endpoints under one key because they are one screen's data:
|
|
4
|
+
// `MarketingCatalogPage` reads the projections and the active-locale set
|
|
5
|
+
// together, and today it reaches the second one with a hand-written `fetch`
|
|
6
|
+
// through its own `httpClient` prop while the first goes through
|
|
7
|
+
// `useMarketingProjections`. One resource, two stems.
|
|
8
|
+
//
|
|
9
|
+
// The list filter keeps the composable's parameter order — `projectKey`,
|
|
10
|
+
// `targetType`, `targetVersionId`, `locale` — because that is the URL the
|
|
11
|
+
// page requests today and a reordered query string is a different request to
|
|
12
|
+
// anything that caches or logs one.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
CreateMarketingProjectionData,
|
|
16
|
+
MarketingProjectionRow,
|
|
17
|
+
MarketingSettingsRow,
|
|
18
|
+
UpdateMarketingProjectionData,
|
|
19
|
+
} from '@saasicat/types';
|
|
20
|
+
|
|
21
|
+
import { defineResource, type ResourceContext } from './define-resource.js';
|
|
22
|
+
import { requestJson, requestJsonBody } from './resource-request.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What the projection list can be narrowed by, beyond the bound project.
|
|
26
|
+
*
|
|
27
|
+
* `projectKey` is deliberately absent: it comes from the context, so a page
|
|
28
|
+
* cannot list one project's projections while creating in another.
|
|
29
|
+
*/
|
|
30
|
+
export interface MarketingProjectionsFilter {
|
|
31
|
+
targetType?: string;
|
|
32
|
+
targetVersionId?: string;
|
|
33
|
+
locale?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function projectionsUrl(ctx: ResourceContext): string {
|
|
37
|
+
return `${ctx.apiBase}/catalog/marketing-projections`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function settingsUrl(ctx: ResourceContext): string {
|
|
41
|
+
return `${ctx.apiBase}/catalog/marketing-settings`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const marketingResource = defineResource(
|
|
45
|
+
'marketing',
|
|
46
|
+
{
|
|
47
|
+
listProjections: async (
|
|
48
|
+
http,
|
|
49
|
+
ctx,
|
|
50
|
+
filter: MarketingProjectionsFilter = {},
|
|
51
|
+
): Promise<MarketingProjectionRow[]> => {
|
|
52
|
+
const params = new URLSearchParams();
|
|
53
|
+
params.set('projectKey', ctx.projectKey);
|
|
54
|
+
if (filter.targetType) params.set('targetType', filter.targetType);
|
|
55
|
+
if (filter.targetVersionId) params.set('targetVersionId', filter.targetVersionId);
|
|
56
|
+
if (filter.locale) params.set('locale', filter.locale);
|
|
57
|
+
return (
|
|
58
|
+
(await requestJson<MarketingProjectionRow[]>(
|
|
59
|
+
http,
|
|
60
|
+
`${projectionsUrl(ctx)}?${params.toString()}`,
|
|
61
|
+
)) ?? []
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The request only. `useMarketingProjections.create` reloads the list
|
|
67
|
+
* afterwards because a unique-tuple insert can change what the filter
|
|
68
|
+
* matches — that is the composable keeping its refs true, not part of
|
|
69
|
+
* the call.
|
|
70
|
+
*/
|
|
71
|
+
createProjection: (
|
|
72
|
+
http,
|
|
73
|
+
ctx,
|
|
74
|
+
data: CreateMarketingProjectionData,
|
|
75
|
+
): Promise<MarketingProjectionRow> =>
|
|
76
|
+
requestJsonBody<MarketingProjectionRow>(
|
|
77
|
+
http,
|
|
78
|
+
projectionsUrl(ctx),
|
|
79
|
+
'Create returned no body',
|
|
80
|
+
{ method: 'POST', body: data },
|
|
81
|
+
),
|
|
82
|
+
|
|
83
|
+
updateProjection: (
|
|
84
|
+
http,
|
|
85
|
+
ctx,
|
|
86
|
+
id: string,
|
|
87
|
+
data: UpdateMarketingProjectionData,
|
|
88
|
+
): Promise<MarketingProjectionRow> =>
|
|
89
|
+
requestJsonBody<MarketingProjectionRow>(
|
|
90
|
+
http,
|
|
91
|
+
`${projectionsUrl(ctx)}/${id}`,
|
|
92
|
+
'Update returned no body',
|
|
93
|
+
{ method: 'PATCH', body: data },
|
|
94
|
+
),
|
|
95
|
+
|
|
96
|
+
deleteProjection: async (http, ctx, id: string): Promise<void> => {
|
|
97
|
+
await requestJson(http, `${projectionsUrl(ctx)}/${id}`, { method: 'DELETE' });
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The activated locale subset, or `null` when none was ever stored.
|
|
102
|
+
*
|
|
103
|
+
* `null` means the full pool is active — the server's answer for "no
|
|
104
|
+
* row", and the state the page falls back to.
|
|
105
|
+
*/
|
|
106
|
+
settings: (http, ctx): Promise<MarketingSettingsRow | null> =>
|
|
107
|
+
requestJson<MarketingSettingsRow>(
|
|
108
|
+
http,
|
|
109
|
+
`${settingsUrl(ctx)}?projectKey=${encodeURIComponent(ctx.projectKey)}`,
|
|
110
|
+
),
|
|
111
|
+
|
|
112
|
+
/** `PUT`, not `PATCH`: the endpoint replaces the set it is given. */
|
|
113
|
+
saveSettings: async (http, ctx, activeLocales: readonly string[]): Promise<void> => {
|
|
114
|
+
await requestJson(http, settingsUrl(ctx), {
|
|
115
|
+
method: 'PUT',
|
|
116
|
+
body: { projectKey: ctx.projectKey, activeLocales },
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{ projectScoped: true },
|
|
121
|
+
);
|