@byline/admin 4.4.1 → 4.6.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/dist/modules/admin-preferences/commands.d.ts +23 -0
- package/dist/modules/admin-preferences/commands.js +31 -0
- package/dist/modules/admin-preferences/index.d.ts +22 -0
- package/dist/modules/admin-preferences/index.js +3 -0
- package/dist/modules/admin-preferences/repository.d.ts +35 -0
- package/dist/modules/admin-preferences/repository.js +1 -0
- package/dist/modules/admin-preferences/schemas.d.ts +50 -0
- package/dist/modules/admin-preferences/schemas.js +23 -0
- package/dist/modules/admin-preferences/schemas.test.node.d.ts +8 -0
- package/dist/modules/admin-preferences/service.d.ts +22 -0
- package/dist/modules/admin-preferences/service.js +56 -0
- package/dist/store.d.ts +3 -1
- package/package.json +10 -5
- package/src/modules/admin-preferences/commands.ts +57 -0
- package/src/modules/admin-preferences/index.ts +37 -0
- package/src/modules/admin-preferences/repository.ts +42 -0
- package/src/modules/admin-preferences/schemas.test.node.ts +67 -0
- package/src/modules/admin-preferences/schemas.ts +62 -0
- package/src/modules/admin-preferences/service.ts +38 -0
- package/src/store.ts +3 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Transport-agnostic commands for per-user admin preferences.
|
|
10
|
+
*
|
|
11
|
+
* Same self-service posture as admin-account: `auth` is
|
|
12
|
+
* `{ authenticated: true }` (no ability key), and the security property
|
|
13
|
+
* "you may only touch your own preferences" is structural — the target
|
|
14
|
+
* id comes from `actor.id`, never from the request payload.
|
|
15
|
+
*/
|
|
16
|
+
import { type Command } from '../../lib/create-command.js';
|
|
17
|
+
import type { AdminStore } from '../../store.js';
|
|
18
|
+
import type { GetPreferenceRequest, PreferenceResponse, SetPreferenceRequest } from './schemas.js';
|
|
19
|
+
export interface AdminPreferencesCommandDeps {
|
|
20
|
+
store: AdminStore;
|
|
21
|
+
}
|
|
22
|
+
export declare const getPreferenceCommand: Command<GetPreferenceRequest, PreferenceResponse, AdminPreferencesCommandDeps>;
|
|
23
|
+
export declare const setPreferenceCommand: Command<SetPreferenceRequest, PreferenceResponse, AdminPreferencesCommandDeps>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createCommand } from "../../lib/create-command.js";
|
|
2
|
+
import { getPreferenceRequestSchema, preferenceResponseSchema, setPreferenceRequestSchema } from "./schemas.js";
|
|
3
|
+
import { AdminPreferencesService } from "./service.js";
|
|
4
|
+
function serviceOf(deps) {
|
|
5
|
+
return new AdminPreferencesService({
|
|
6
|
+
repo: deps.store.adminPreferences
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
const getPreferenceCommand = createCommand({
|
|
10
|
+
method: 'getPreference',
|
|
11
|
+
auth: {
|
|
12
|
+
authenticated: true
|
|
13
|
+
},
|
|
14
|
+
schemas: {
|
|
15
|
+
input: getPreferenceRequestSchema,
|
|
16
|
+
output: preferenceResponseSchema
|
|
17
|
+
},
|
|
18
|
+
handler: ({ input, deps, actor })=>serviceOf(deps).getPreference(actor.id, input.scope)
|
|
19
|
+
});
|
|
20
|
+
const setPreferenceCommand = createCommand({
|
|
21
|
+
method: 'setPreference',
|
|
22
|
+
auth: {
|
|
23
|
+
authenticated: true
|
|
24
|
+
},
|
|
25
|
+
schemas: {
|
|
26
|
+
input: setPreferenceRequestSchema,
|
|
27
|
+
output: preferenceResponseSchema
|
|
28
|
+
},
|
|
29
|
+
handler: ({ input, deps, actor })=>serviceOf(deps).setPreference(actor.id, input.scope, input.value)
|
|
30
|
+
});
|
|
31
|
+
export { getPreferenceCommand, setPreferenceCommand };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* `@byline/admin/admin-preferences` — scoped per-user key-value
|
|
10
|
+
* preferences for the currently signed-in admin user.
|
|
11
|
+
*
|
|
12
|
+
* Self-service like `@byline/admin/admin-account`: the actor IS the
|
|
13
|
+
* target, and there is no ability gate — authn-only. The `scope` string
|
|
14
|
+
* (e.g. `collections.docs.list`) is the generality lever: new admin
|
|
15
|
+
* surfaces claim their own scopes with no schema change.
|
|
16
|
+
*/
|
|
17
|
+
export { getPreferenceCommand, setPreferenceCommand } from './commands.js';
|
|
18
|
+
export { getPreferenceRequestSchema, listViewPreferenceValueSchema, preferenceResponseSchema, preferenceScopeSchema, setPreferenceRequestSchema, } from './schemas.js';
|
|
19
|
+
export { AdminPreferencesService } from './service.js';
|
|
20
|
+
export type { AdminPreferencesCommandDeps } from './commands.js';
|
|
21
|
+
export type { AdminPreferencesRepository, AdminUserPreferenceRow, } from './repository.js';
|
|
22
|
+
export type { GetPreferenceRequest, PreferenceResponse, SetPreferenceRequest, } from './schemas.js';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { getPreferenceCommand, setPreferenceCommand } from "./commands.js";
|
|
2
|
+
export { getPreferenceRequestSchema, listViewPreferenceValueSchema, preferenceResponseSchema, preferenceScopeSchema, setPreferenceRequestSchema } from "./schemas.js";
|
|
3
|
+
export { AdminPreferencesService } from "./service.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* `AdminPreferencesRepository` — the DB-adapter-facing contract for the
|
|
10
|
+
* `byline_admin_user_preferences` table: a scoped per-user key-value
|
|
11
|
+
* store. One row per (user, scope); `value` is a JSONB object whose
|
|
12
|
+
* shape is owned by the scope's feature (validated at the command
|
|
13
|
+
* layer, not here).
|
|
14
|
+
*
|
|
15
|
+
* Adapters (e.g. `@byline/db-postgres`) implement this interface; the
|
|
16
|
+
* admin-preferences service consumes it via the `AdminStore` bundle.
|
|
17
|
+
*/
|
|
18
|
+
export interface AdminUserPreferenceRow {
|
|
19
|
+
user_id: string;
|
|
20
|
+
scope: string;
|
|
21
|
+
value: Record<string, unknown>;
|
|
22
|
+
created_at: Date;
|
|
23
|
+
updated_at: Date;
|
|
24
|
+
}
|
|
25
|
+
export interface AdminPreferencesRepository {
|
|
26
|
+
/** `null` when the user has no row for the scope. */
|
|
27
|
+
get(userId: string, scope: string): Promise<AdminUserPreferenceRow | null>;
|
|
28
|
+
/**
|
|
29
|
+
* Insert-or-merge. On conflict the JSONB `patch` is merged into the
|
|
30
|
+
* stored value **per key** (`value || patch`), so writing
|
|
31
|
+
* `{ page_size }` preserves a previously stored `order`/`desc`.
|
|
32
|
+
* Vid-less — preferences are last-writer-wins by design.
|
|
33
|
+
*/
|
|
34
|
+
upsert(userId: string, scope: string, patch: Record<string, unknown>): Promise<AdminUserPreferenceRow>;
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Zod schemas for the admin-preferences commands.
|
|
10
|
+
*
|
|
11
|
+
* Self-service, like admin-account: none of the request schemas accept a
|
|
12
|
+
* user id — the command resolves the target from `actor.id`.
|
|
13
|
+
*
|
|
14
|
+
* The `value` payload is validated against the list-view shape because
|
|
15
|
+
* that is the only registered scope family today
|
|
16
|
+
* (`collections.<path>.list`). When a second scope family arrives, this
|
|
17
|
+
* becomes a scope-keyed selection of value schemas.
|
|
18
|
+
*/
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
/** Dot-separated segment key, e.g. `collections.docs.list`. */
|
|
21
|
+
export declare const preferenceScopeSchema: z.ZodString;
|
|
22
|
+
/**
|
|
23
|
+
* Sticky list-view keys. All optional — clients send only the keys the
|
|
24
|
+
* interaction changed, and the repository merges per-key — but an empty
|
|
25
|
+
* object is rejected (nothing to write).
|
|
26
|
+
*/
|
|
27
|
+
export declare const listViewPreferenceValueSchema: z.ZodObject<{
|
|
28
|
+
page_size: z.ZodOptional<z.ZodNumber>;
|
|
29
|
+
order: z.ZodOptional<z.ZodString>;
|
|
30
|
+
desc: z.ZodOptional<z.ZodBoolean>;
|
|
31
|
+
}, z.core.$strict>;
|
|
32
|
+
export declare const getPreferenceRequestSchema: z.ZodObject<{
|
|
33
|
+
scope: z.ZodString;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
export type GetPreferenceRequest = z.infer<typeof getPreferenceRequestSchema>;
|
|
36
|
+
export declare const setPreferenceRequestSchema: z.ZodObject<{
|
|
37
|
+
scope: z.ZodString;
|
|
38
|
+
value: z.ZodObject<{
|
|
39
|
+
page_size: z.ZodOptional<z.ZodNumber>;
|
|
40
|
+
order: z.ZodOptional<z.ZodString>;
|
|
41
|
+
desc: z.ZodOptional<z.ZodBoolean>;
|
|
42
|
+
}, z.core.$strict>;
|
|
43
|
+
}, z.core.$strip>;
|
|
44
|
+
export type SetPreferenceRequest = z.infer<typeof setPreferenceRequestSchema>;
|
|
45
|
+
/** `value` is `null` when the user has no stored preference for the scope. */
|
|
46
|
+
export declare const preferenceResponseSchema: z.ZodObject<{
|
|
47
|
+
scope: z.ZodString;
|
|
48
|
+
value: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
49
|
+
}, z.core.$strip>;
|
|
50
|
+
export type PreferenceResponse = z.infer<typeof preferenceResponseSchema>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const preferenceScopeSchema = z.string().min(1).max(255).regex(/^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/i, {
|
|
3
|
+
message: 'scope must be dot-separated segments of [a-z0-9_-]'
|
|
4
|
+
});
|
|
5
|
+
const listViewPreferenceValueSchema = z.object({
|
|
6
|
+
page_size: z.number().int().min(1).max(100).optional(),
|
|
7
|
+
order: z.string().min(1).max(255).optional(),
|
|
8
|
+
desc: z.boolean().optional()
|
|
9
|
+
}).strict().refine((v)=>Object.keys(v).length > 0, {
|
|
10
|
+
message: 'value cannot be empty'
|
|
11
|
+
});
|
|
12
|
+
const getPreferenceRequestSchema = z.object({
|
|
13
|
+
scope: preferenceScopeSchema
|
|
14
|
+
});
|
|
15
|
+
const setPreferenceRequestSchema = z.object({
|
|
16
|
+
scope: preferenceScopeSchema,
|
|
17
|
+
value: listViewPreferenceValueSchema
|
|
18
|
+
});
|
|
19
|
+
const preferenceResponseSchema = z.object({
|
|
20
|
+
scope: z.string(),
|
|
21
|
+
value: z.record(z.string(), z.unknown()).nullable()
|
|
22
|
+
});
|
|
23
|
+
export { getPreferenceRequestSchema, listViewPreferenceValueSchema, preferenceResponseSchema, preferenceScopeSchema, setPreferenceRequestSchema };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Self-service business logic for per-user admin preferences. Every
|
|
10
|
+
* method takes `actorId` sourced server-side from the authenticated
|
|
11
|
+
* `RequestContext` — callers cannot supply a target user id.
|
|
12
|
+
*/
|
|
13
|
+
import type { AdminPreferencesRepository } from './repository.js';
|
|
14
|
+
import type { PreferenceResponse } from './schemas.js';
|
|
15
|
+
export declare class AdminPreferencesService {
|
|
16
|
+
#private;
|
|
17
|
+
constructor(deps: {
|
|
18
|
+
repo: AdminPreferencesRepository;
|
|
19
|
+
});
|
|
20
|
+
getPreference(actorId: string, scope: string): Promise<PreferenceResponse>;
|
|
21
|
+
setPreference(actorId: string, scope: string, patch: Record<string, unknown>): Promise<PreferenceResponse>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
function _check_private_redeclaration(obj, privateCollection) {
|
|
2
|
+
if (privateCollection.has(obj)) throw new TypeError("Cannot initialize the same private elements twice on an object");
|
|
3
|
+
}
|
|
4
|
+
function _class_apply_descriptor_get(receiver, descriptor) {
|
|
5
|
+
if (descriptor.get) return descriptor.get.call(receiver);
|
|
6
|
+
return descriptor.value;
|
|
7
|
+
}
|
|
8
|
+
function _class_apply_descriptor_set(receiver, descriptor, value) {
|
|
9
|
+
if (descriptor.set) descriptor.set.call(receiver, value);
|
|
10
|
+
else {
|
|
11
|
+
if (!descriptor.writable) throw new TypeError("attempted to set read only private field");
|
|
12
|
+
descriptor.value = value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function _class_extract_field_descriptor(receiver, privateMap, action) {
|
|
16
|
+
if (!privateMap.has(receiver)) throw new TypeError("attempted to " + action + " private field on non-instance");
|
|
17
|
+
return privateMap.get(receiver);
|
|
18
|
+
}
|
|
19
|
+
function _class_private_field_get(receiver, privateMap) {
|
|
20
|
+
var descriptor = _class_extract_field_descriptor(receiver, privateMap, "get");
|
|
21
|
+
return _class_apply_descriptor_get(receiver, descriptor);
|
|
22
|
+
}
|
|
23
|
+
function _class_private_field_init(obj, privateMap, value) {
|
|
24
|
+
_check_private_redeclaration(obj, privateMap);
|
|
25
|
+
privateMap.set(obj, value);
|
|
26
|
+
}
|
|
27
|
+
function _class_private_field_set(receiver, privateMap, value) {
|
|
28
|
+
var descriptor = _class_extract_field_descriptor(receiver, privateMap, "set");
|
|
29
|
+
_class_apply_descriptor_set(receiver, descriptor, value);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
var _repo = /*#__PURE__*/ new WeakMap();
|
|
33
|
+
class AdminPreferencesService {
|
|
34
|
+
async getPreference(actorId, scope) {
|
|
35
|
+
const row = await _class_private_field_get(this, _repo).get(actorId, scope);
|
|
36
|
+
return {
|
|
37
|
+
scope,
|
|
38
|
+
value: row?.value ?? null
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async setPreference(actorId, scope, patch) {
|
|
42
|
+
const row = await _class_private_field_get(this, _repo).upsert(actorId, scope, patch);
|
|
43
|
+
return {
|
|
44
|
+
scope,
|
|
45
|
+
value: row.value
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
constructor(deps){
|
|
49
|
+
_class_private_field_init(this, _repo, {
|
|
50
|
+
writable: true,
|
|
51
|
+
value: void 0
|
|
52
|
+
});
|
|
53
|
+
_class_private_field_set(this, _repo, deps.repo);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export { AdminPreferencesService };
|
package/dist/store.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import type { AdminPermissionsRepository } from './modules/admin-permissions/repository.js';
|
|
9
|
+
import type { AdminPreferencesRepository } from './modules/admin-preferences/repository.js';
|
|
9
10
|
import type { AdminRolesRepository } from './modules/admin-roles/repository.js';
|
|
10
11
|
import type { AdminUsersRepository } from './modules/admin-users/repository.js';
|
|
11
12
|
import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repository.js';
|
|
@@ -18,7 +19,7 @@ import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repo
|
|
|
18
19
|
* `JwtSessionProvider`, to `seedSuperAdmin`, and (later) to admin-user
|
|
19
20
|
* and admin-role commands.
|
|
20
21
|
*
|
|
21
|
-
* Keeping the
|
|
22
|
+
* Keeping the five repositories together as a single argument avoids
|
|
22
23
|
* exploding constructor signatures and makes "needs admin DB access" a
|
|
23
24
|
* single, recognisable type.
|
|
24
25
|
*/
|
|
@@ -27,4 +28,5 @@ export interface AdminStore {
|
|
|
27
28
|
adminRoles: AdminRolesRepository;
|
|
28
29
|
adminPermissions: AdminPermissionsRepository;
|
|
29
30
|
refreshTokens: RefreshTokensRepository;
|
|
31
|
+
adminPreferences: AdminPreferencesRepository;
|
|
30
32
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.6.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -72,6 +72,11 @@
|
|
|
72
72
|
"import": "./dist/modules/admin-account/index.js",
|
|
73
73
|
"require": "./dist/modules/admin-account/index.js"
|
|
74
74
|
},
|
|
75
|
+
"./admin-preferences": {
|
|
76
|
+
"types": "./dist/modules/admin-preferences/index.d.ts",
|
|
77
|
+
"import": "./dist/modules/admin-preferences/index.js",
|
|
78
|
+
"require": "./dist/modules/admin-preferences/index.js"
|
|
79
|
+
},
|
|
75
80
|
"./services": {
|
|
76
81
|
"types": "./dist/services/admin-services-context.d.ts",
|
|
77
82
|
"import": "./dist/services/admin-services-context.js",
|
|
@@ -168,10 +173,10 @@
|
|
|
168
173
|
"react-diff-viewer-continued": "^4.4.0",
|
|
169
174
|
"uuid": "^14.0.1",
|
|
170
175
|
"zod": "^4.4.3",
|
|
171
|
-
"@byline/auth": "4.
|
|
172
|
-
"@byline/ui": "4.
|
|
173
|
-
"@byline/
|
|
174
|
-
"@byline/
|
|
176
|
+
"@byline/auth": "4.6.0",
|
|
177
|
+
"@byline/ui": "4.6.0",
|
|
178
|
+
"@byline/core": "4.6.0",
|
|
179
|
+
"@byline/i18n": "4.6.0"
|
|
175
180
|
},
|
|
176
181
|
"peerDependencies": {
|
|
177
182
|
"react": "^19.0.0",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Transport-agnostic commands for per-user admin preferences.
|
|
11
|
+
*
|
|
12
|
+
* Same self-service posture as admin-account: `auth` is
|
|
13
|
+
* `{ authenticated: true }` (no ability key), and the security property
|
|
14
|
+
* "you may only touch your own preferences" is structural — the target
|
|
15
|
+
* id comes from `actor.id`, never from the request payload.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { type Command, createCommand } from '../../lib/create-command.js'
|
|
19
|
+
import {
|
|
20
|
+
getPreferenceRequestSchema,
|
|
21
|
+
preferenceResponseSchema,
|
|
22
|
+
setPreferenceRequestSchema,
|
|
23
|
+
} from './schemas.js'
|
|
24
|
+
import { AdminPreferencesService } from './service.js'
|
|
25
|
+
import type { AdminStore } from '../../store.js'
|
|
26
|
+
import type { GetPreferenceRequest, PreferenceResponse, SetPreferenceRequest } from './schemas.js'
|
|
27
|
+
|
|
28
|
+
export interface AdminPreferencesCommandDeps {
|
|
29
|
+
store: AdminStore
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function serviceOf(deps: AdminPreferencesCommandDeps): AdminPreferencesService {
|
|
33
|
+
return new AdminPreferencesService({ repo: deps.store.adminPreferences })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const getPreferenceCommand: Command<
|
|
37
|
+
GetPreferenceRequest,
|
|
38
|
+
PreferenceResponse,
|
|
39
|
+
AdminPreferencesCommandDeps
|
|
40
|
+
> = createCommand({
|
|
41
|
+
method: 'getPreference',
|
|
42
|
+
auth: { authenticated: true },
|
|
43
|
+
schemas: { input: getPreferenceRequestSchema, output: preferenceResponseSchema },
|
|
44
|
+
handler: ({ input, deps, actor }) => serviceOf(deps).getPreference(actor.id, input.scope),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export const setPreferenceCommand: Command<
|
|
48
|
+
SetPreferenceRequest,
|
|
49
|
+
PreferenceResponse,
|
|
50
|
+
AdminPreferencesCommandDeps
|
|
51
|
+
> = createCommand({
|
|
52
|
+
method: 'setPreference',
|
|
53
|
+
auth: { authenticated: true },
|
|
54
|
+
schemas: { input: setPreferenceRequestSchema, output: preferenceResponseSchema },
|
|
55
|
+
handler: ({ input, deps, actor }) =>
|
|
56
|
+
serviceOf(deps).setPreference(actor.id, input.scope, input.value),
|
|
57
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `@byline/admin/admin-preferences` — scoped per-user key-value
|
|
11
|
+
* preferences for the currently signed-in admin user.
|
|
12
|
+
*
|
|
13
|
+
* Self-service like `@byline/admin/admin-account`: the actor IS the
|
|
14
|
+
* target, and there is no ability gate — authn-only. The `scope` string
|
|
15
|
+
* (e.g. `collections.docs.list`) is the generality lever: new admin
|
|
16
|
+
* surfaces claim their own scopes with no schema change.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { getPreferenceCommand, setPreferenceCommand } from './commands.js'
|
|
20
|
+
export {
|
|
21
|
+
getPreferenceRequestSchema,
|
|
22
|
+
listViewPreferenceValueSchema,
|
|
23
|
+
preferenceResponseSchema,
|
|
24
|
+
preferenceScopeSchema,
|
|
25
|
+
setPreferenceRequestSchema,
|
|
26
|
+
} from './schemas.js'
|
|
27
|
+
export { AdminPreferencesService } from './service.js'
|
|
28
|
+
export type { AdminPreferencesCommandDeps } from './commands.js'
|
|
29
|
+
export type {
|
|
30
|
+
AdminPreferencesRepository,
|
|
31
|
+
AdminUserPreferenceRow,
|
|
32
|
+
} from './repository.js'
|
|
33
|
+
export type {
|
|
34
|
+
GetPreferenceRequest,
|
|
35
|
+
PreferenceResponse,
|
|
36
|
+
SetPreferenceRequest,
|
|
37
|
+
} from './schemas.js'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `AdminPreferencesRepository` — the DB-adapter-facing contract for the
|
|
11
|
+
* `byline_admin_user_preferences` table: a scoped per-user key-value
|
|
12
|
+
* store. One row per (user, scope); `value` is a JSONB object whose
|
|
13
|
+
* shape is owned by the scope's feature (validated at the command
|
|
14
|
+
* layer, not here).
|
|
15
|
+
*
|
|
16
|
+
* Adapters (e.g. `@byline/db-postgres`) implement this interface; the
|
|
17
|
+
* admin-preferences service consumes it via the `AdminStore` bundle.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface AdminUserPreferenceRow {
|
|
21
|
+
user_id: string
|
|
22
|
+
scope: string
|
|
23
|
+
value: Record<string, unknown>
|
|
24
|
+
created_at: Date
|
|
25
|
+
updated_at: Date
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AdminPreferencesRepository {
|
|
29
|
+
/** `null` when the user has no row for the scope. */
|
|
30
|
+
get(userId: string, scope: string): Promise<AdminUserPreferenceRow | null>
|
|
31
|
+
/**
|
|
32
|
+
* Insert-or-merge. On conflict the JSONB `patch` is merged into the
|
|
33
|
+
* stored value **per key** (`value || patch`), so writing
|
|
34
|
+
* `{ page_size }` preserves a previously stored `order`/`desc`.
|
|
35
|
+
* Vid-less — preferences are last-writer-wins by design.
|
|
36
|
+
*/
|
|
37
|
+
upsert(
|
|
38
|
+
userId: string,
|
|
39
|
+
scope: string,
|
|
40
|
+
patch: Record<string, unknown>
|
|
41
|
+
): Promise<AdminUserPreferenceRow>
|
|
42
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
listViewPreferenceValueSchema,
|
|
13
|
+
preferenceScopeSchema,
|
|
14
|
+
setPreferenceRequestSchema,
|
|
15
|
+
} from './schemas.js'
|
|
16
|
+
|
|
17
|
+
describe('preferenceScopeSchema', () => {
|
|
18
|
+
it('accepts dot-separated scope keys', () => {
|
|
19
|
+
expect(preferenceScopeSchema.safeParse('collections.docs.list').success).toBe(true)
|
|
20
|
+
expect(preferenceScopeSchema.safeParse('collections.media-items.list').success).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('rejects empty, spaced, and slash-separated keys', () => {
|
|
24
|
+
expect(preferenceScopeSchema.safeParse('').success).toBe(false)
|
|
25
|
+
expect(preferenceScopeSchema.safeParse('has space').success).toBe(false)
|
|
26
|
+
expect(preferenceScopeSchema.safeParse('a/b').success).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('listViewPreferenceValueSchema', () => {
|
|
31
|
+
it('accepts a page_size-only payload (partial writes are the norm)', () => {
|
|
32
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 50 }).success).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('accepts a sort-only payload', () => {
|
|
36
|
+
expect(listViewPreferenceValueSchema.safeParse({ order: 'title', desc: true }).success).toBe(
|
|
37
|
+
true
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('enforces the 1-100 page_size bounds', () => {
|
|
42
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 0 }).success).toBe(false)
|
|
43
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 101 }).success).toBe(false)
|
|
44
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 12.5 }).success).toBe(false)
|
|
45
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 1 }).success).toBe(true)
|
|
46
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 100 }).success).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('rejects an empty payload and unknown keys', () => {
|
|
50
|
+
expect(listViewPreferenceValueSchema.safeParse({}).success).toBe(false)
|
|
51
|
+
expect(listViewPreferenceValueSchema.safeParse({ page: 7 }).success).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('setPreferenceRequestSchema', () => {
|
|
56
|
+
it('requires both scope and a non-empty value', () => {
|
|
57
|
+
expect(
|
|
58
|
+
setPreferenceRequestSchema.safeParse({
|
|
59
|
+
scope: 'collections.docs.list',
|
|
60
|
+
value: { page_size: 30 },
|
|
61
|
+
}).success
|
|
62
|
+
).toBe(true)
|
|
63
|
+
expect(
|
|
64
|
+
setPreferenceRequestSchema.safeParse({ scope: 'collections.docs.list', value: {} }).success
|
|
65
|
+
).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Zod schemas for the admin-preferences commands.
|
|
11
|
+
*
|
|
12
|
+
* Self-service, like admin-account: none of the request schemas accept a
|
|
13
|
+
* user id — the command resolves the target from `actor.id`.
|
|
14
|
+
*
|
|
15
|
+
* The `value` payload is validated against the list-view shape because
|
|
16
|
+
* that is the only registered scope family today
|
|
17
|
+
* (`collections.<path>.list`). When a second scope family arrives, this
|
|
18
|
+
* becomes a scope-keyed selection of value schemas.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { z } from 'zod'
|
|
22
|
+
|
|
23
|
+
/** Dot-separated segment key, e.g. `collections.docs.list`. */
|
|
24
|
+
export const preferenceScopeSchema = z
|
|
25
|
+
.string()
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(255)
|
|
28
|
+
.regex(/^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/i, {
|
|
29
|
+
message: 'scope must be dot-separated segments of [a-z0-9_-]',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sticky list-view keys. All optional — clients send only the keys the
|
|
34
|
+
* interaction changed, and the repository merges per-key — but an empty
|
|
35
|
+
* object is rejected (nothing to write).
|
|
36
|
+
*/
|
|
37
|
+
export const listViewPreferenceValueSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
page_size: z.number().int().min(1).max(100).optional(),
|
|
40
|
+
order: z.string().min(1).max(255).optional(),
|
|
41
|
+
desc: z.boolean().optional(),
|
|
42
|
+
})
|
|
43
|
+
.strict()
|
|
44
|
+
.refine((v) => Object.keys(v).length > 0, { message: 'value cannot be empty' })
|
|
45
|
+
|
|
46
|
+
export const getPreferenceRequestSchema = z.object({
|
|
47
|
+
scope: preferenceScopeSchema,
|
|
48
|
+
})
|
|
49
|
+
export type GetPreferenceRequest = z.infer<typeof getPreferenceRequestSchema>
|
|
50
|
+
|
|
51
|
+
export const setPreferenceRequestSchema = z.object({
|
|
52
|
+
scope: preferenceScopeSchema,
|
|
53
|
+
value: listViewPreferenceValueSchema,
|
|
54
|
+
})
|
|
55
|
+
export type SetPreferenceRequest = z.infer<typeof setPreferenceRequestSchema>
|
|
56
|
+
|
|
57
|
+
/** `value` is `null` when the user has no stored preference for the scope. */
|
|
58
|
+
export const preferenceResponseSchema = z.object({
|
|
59
|
+
scope: z.string(),
|
|
60
|
+
value: z.record(z.string(), z.unknown()).nullable(),
|
|
61
|
+
})
|
|
62
|
+
export type PreferenceResponse = z.infer<typeof preferenceResponseSchema>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Self-service business logic for per-user admin preferences. Every
|
|
11
|
+
* method takes `actorId` sourced server-side from the authenticated
|
|
12
|
+
* `RequestContext` — callers cannot supply a target user id.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AdminPreferencesRepository } from './repository.js'
|
|
16
|
+
import type { PreferenceResponse } from './schemas.js'
|
|
17
|
+
|
|
18
|
+
export class AdminPreferencesService {
|
|
19
|
+
readonly #repo: AdminPreferencesRepository
|
|
20
|
+
|
|
21
|
+
constructor(deps: { repo: AdminPreferencesRepository }) {
|
|
22
|
+
this.#repo = deps.repo
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async getPreference(actorId: string, scope: string): Promise<PreferenceResponse> {
|
|
26
|
+
const row = await this.#repo.get(actorId, scope)
|
|
27
|
+
return { scope, value: row?.value ?? null }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async setPreference(
|
|
31
|
+
actorId: string,
|
|
32
|
+
scope: string,
|
|
33
|
+
patch: Record<string, unknown>
|
|
34
|
+
): Promise<PreferenceResponse> {
|
|
35
|
+
const row = await this.#repo.upsert(actorId, scope, patch)
|
|
36
|
+
return { scope, value: row.value }
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { AdminPermissionsRepository } from './modules/admin-permissions/repository.js'
|
|
10
|
+
import type { AdminPreferencesRepository } from './modules/admin-preferences/repository.js'
|
|
10
11
|
import type { AdminRolesRepository } from './modules/admin-roles/repository.js'
|
|
11
12
|
import type { AdminUsersRepository } from './modules/admin-users/repository.js'
|
|
12
13
|
import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repository.js'
|
|
@@ -20,7 +21,7 @@ import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repo
|
|
|
20
21
|
* `JwtSessionProvider`, to `seedSuperAdmin`, and (later) to admin-user
|
|
21
22
|
* and admin-role commands.
|
|
22
23
|
*
|
|
23
|
-
* Keeping the
|
|
24
|
+
* Keeping the five repositories together as a single argument avoids
|
|
24
25
|
* exploding constructor signatures and makes "needs admin DB access" a
|
|
25
26
|
* single, recognisable type.
|
|
26
27
|
*/
|
|
@@ -29,4 +30,5 @@ export interface AdminStore {
|
|
|
29
30
|
adminRoles: AdminRolesRepository
|
|
30
31
|
adminPermissions: AdminPermissionsRepository
|
|
31
32
|
refreshTokens: RefreshTokensRepository
|
|
33
|
+
adminPreferences: AdminPreferencesRepository
|
|
32
34
|
}
|