Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 4.3.7
- Added `DataAccessor.describeAccessibleFields()` to expose per-action field metadata for AI and form builders.
- Extended the fixture OpenAI agent with schema introspection, payload sanitisation, and required-field validation when creating records.
- Documented the new workflow and updated tests to cover the field discovery command.

## 4.3.6
- Configured OpenAI API integration through environment variables for AI assistant functionality.
- Added dotenv support for loading environment variables from .env file in fixture startup.
Expand Down
32 changes: 30 additions & 2 deletions docs/AccessRights/AccessRightsModelFields.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,33 @@ In this example:

* The `title` field is visible to everyone.
* `internalStatus` is only shown to admins and QA team members.
* `createdAt` is hidden entirely.
* Only records where the current user is the `owner` are accessible to non-admins.
* `createdAt` is hidden entirely.
* Only records where the current user is the `owner` are accessible to non-admins.

---

#### **Field Metadata Export (`describeAccessibleFields`)**

When building automated tooling—such as the fixture's OpenAI agent—it is often necessary to know not only which fields are
visible, but also how they should be populated. The new `describeAccessibleFields()` helper returns a curated list of descriptors
for the current action, honouring all checks described above. Each descriptor contains:

* `key`, `title`, and `type` — the normalized field metadata.
* `required` / `disabled` flags — so clients know whether a value must be supplied.
* `description`, `placeholder`, `allowedValues`, and `options` — sourced from the field configuration when present.
* `association` details — identifies the linked model and whether the relation accepts multiple entries.

The method is especially useful for AI or form builders because it eliminates guesswork and guarantees parity with the existing
permission model:

```ts
const accessor = new DataAccessor(adminizer, user, entity, 'add');
const fields = accessor.describeAccessibleFields();

fields.forEach((field) => {
console.log(`${field.key} → ${field.type}`);
});
```

Consumers can confidently pre-validate payloads, filter out forbidden attributes, and craft meaningful prompts before they reach
the persistence layer.
19 changes: 19 additions & 0 deletions docs/AiAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ Example payload for creating a record:

If the user lacks the required access token (for example, `create-example-model`), the agent responds with an authorization error instead of touching the database. The `openai` fixture user (`login: openai`, `password: openai`) belongs to the administrators group, granting full access for experimentation. Regular users can be granted permissions by assigning the `ai-assistant-openai` token to their groups.

### Discovering available fields

Before issuing a `create` command the agent can now describe the exact payload shape that is accepted for the chosen model. This
is achieved through the `fields` action which asks `DataAccessor` for the list of writable fields, their types, requirements, and
association hints. The agent trims any values that are not allowed and will stop execution if mandatory properties are missing.

Example request for the schema:

```json
{
"action": "fields",
"entity": "Example"
}
```

The response enumerates each accessible field, including required flags, optional descriptions (taken from field tooltips),
allowed enums, and association targets. When a `create` command is executed afterwards the agent automatically reuses this
metadata to validate the payload and report missing values instead of failing with a generic database error.

## Backend Overview

* `AiAssistantHandler` keeps registered model services and in-memory conversation history per user and model.
Expand Down
107 changes: 107 additions & 0 deletions src/lib/DataAccessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ import { GroupAP } from "models/GroupAP";
import { UserAP } from "models/UserAP";
import { isObject } from "../helpers/JsUtils";

export interface AccessibleFieldDescriptor {
key: string;
title: string;
type: FieldsTypes;
required: boolean;
disabled: boolean;
description?: string;
placeholder?: string;
defaultValue?: unknown;
allowedValues?: unknown[];
options?: Record<string, unknown>;
association?: {
model: string;
multiple: boolean;
} | null;
}

export class DataAccessor {
public readonly adminizer: Adminizer;
user: UserAP;
Expand Down Expand Up @@ -345,6 +362,96 @@ export class DataAccessor {
return filteredAssociatedRecord;
}

public describeAccessibleFields(): AccessibleFieldDescriptor[] {
const fieldsConfig = this.getFieldsConfig();

if (!fieldsConfig) {
return [];
}

const descriptors: AccessibleFieldDescriptor[] = [];

for (const [key, field] of Object.entries(fieldsConfig)) {
if (!field || !isObject(field.config)) {
continue;
}

const config = field.config as (BaseFieldConfig & {
value?: unknown;
default?: unknown;
placeholder?: string;
description?: string;
helperText?: string;
isIn?: unknown;
options?: Record<string, unknown>;
});

if (!this.checkFieldAccess(key, config)) {
continue;
}

const fieldType = (config.type ?? field.model?.type ?? 'string') as FieldsTypes;
const allowedValues = this.extractAllowedValues(config);

const options = isObject(config.options)
? config.options as Record<string, unknown>
: undefined;

const descriptor: AccessibleFieldDescriptor = {
key,
title: config.title ?? key,
type: fieldType,
required: Boolean(config.required),
disabled: Boolean(config.disabled),
description: config.tooltip ?? config.description ?? config.helperText,
placeholder: config.placeholder,
defaultValue: config.value ?? config.default,
allowedValues,
options,
association: this.describeAssociation(fieldType, field),
};

descriptors.push(descriptor);
}

return descriptors;
}

private extractAllowedValues(config: BaseFieldConfig & {isIn?: unknown}): unknown[] | undefined {
if (!config.isIn) {
return undefined;
}

const values = config.isIn;

if (Array.isArray(values)) {
return [...values];
}

if (isObject(values)) {
return Object.entries(values).map(([value, label]) => ({value, label}));
}

return undefined;
}

private describeAssociation(type: FieldsTypes, field: Field): AccessibleFieldDescriptor['association'] {
if (type !== 'association' && type !== 'association-many') {
return null;
}

const targetModel = (field.model?.model ?? field.model?.collection ?? field.model?.ref) as string | undefined;

if (!targetModel) {
return null;
}

return {
model: targetModel,
multiple: type === 'association-many',
};
}

/** Process for an array of records */
public processMany<T>(records: T[]): Partial<T>[] {
return records.map(record => this.process(record));
Expand Down
Loading