Skip to content
Merged
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
70 changes: 40 additions & 30 deletions packages/agent-bff/src/action/action-routes-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AgentActionClient, AgentActionClientOptions } from './agent-action-client';
import type {
Action,
ActionForm,
AgentActionClient,
AgentActionClientOptions,
} from './agent-action-client';
import type { Logger } from '../ports/logger-port';
import type ReadModelStore from '../read-model/read-model-store';
import type { Context, Middleware } from 'koa';
Expand Down Expand Up @@ -77,42 +82,34 @@ export interface ActionRoutesMiddlewareOptions {
createClient?: (options: AgentActionClientOptions) => AgentActionClient;
}

async function handleForm(
ctx: Context,
client: AgentActionClient,
collection: string,
actionName: string,
recordIds: string[],
values: Record<string, unknown>,
logger: Logger,
): Promise<void> {
interface ActionHandlerArgs<TAction extends ActionForm> {
ctx: Context;
action: TAction;
values: Record<string, unknown>;
logger: Logger;
}

async function handleForm({
ctx,
action,
values,
logger,
}: ActionHandlerArgs<ActionForm>): Promise<void> {
// Each agent-hitting call is wrapped on its own; getFields/extractRawLayout/mapping stay outside
// callAgent so a local BFF bug surfaces as a 500, not a mislabelled agent error. Fields and
// layout are read AFTER tryToSetFields because a change hook rebuilds them in place.
const action = await callAgent(
() => client.loadAction(collection, actionName, recordIds),
logger,
);
const skippedFields = await callAgent(() => action.tryToSetFields(values), logger);

ctx.status = 200;
ctx.body = mapActionForm(action, skippedFields, extractRawLayout(action));
}

async function handleExecute(
ctx: Context,
client: AgentActionClient,
collection: string,
actionName: string,
recordIds: string[],
values: Record<string, unknown>,
logger: Logger,
): Promise<void> {
const action = await callAgent(
() => client.loadAction(collection, actionName, recordIds),
logger,
);

async function handleExecute({
ctx,
action,
values,
logger,
}: ActionHandlerArgs<Action>): Promise<void> {
// setFields is strict: an unknown submitted field is a client error (400), not a 500. A transport
// failure from the change-hook it triggers is a genuine agent error, so it goes to the mapper.
try {
Expand Down Expand Up @@ -202,10 +199,23 @@ export default function createActionRoutesMiddleware({
timeoutMs,
});

const action = await callAgent(
() =>
client.loadAction({
collection,
actionName,
recordIds,
timezone: ctx.state.timezone as string,
}),
logger,
);

const handlerArgs = { ctx, action, values, logger };

if (verb === 'execute') {
await handleExecute(ctx, client, collection, actionName, recordIds, values, logger);
await handleExecute(handlerArgs);
} else {
await handleForm(ctx, client, collection, actionName, recordIds, values, logger);
await handleForm(handlerArgs);
}
};
}
15 changes: 11 additions & 4 deletions packages/agent-bff/src/action/agent-action-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ export interface Action extends ActionForm {
execute(): Promise<unknown>;
}

export interface LoadActionParams {
collection: string;
actionName: string;
recordIds: string[];
timezone: string;
Comment thread
Tonours marked this conversation as resolved.
}

export interface AgentActionClient {
loadAction(collection: string, action: string, recordIds: string[]): Promise<Action>;
loadAction(params: LoadActionParams): Promise<Action>;
}

export interface AgentActionClientOptions {
Expand All @@ -42,7 +49,7 @@ export interface AgentActionClientOptions {

// The raw layout must be read AFTER tryToSetFields: a change hook rebuilds fields+layout in place.
// agent-client's `Action.getLayout()` only returns an `ActionLayoutRoot` wrapper whose element array
// lives in a protected field. The rollback contract forbids agent-client changes, so we read it
// lives in a protected field. The rollback contract forbade agent-client changes, so we read it
// through a cast rather than adding a public accessor. `extract-raw-layout.test.ts` builds a real
// `ActionLayoutRoot` and asserts this unwraps it, so a rename of that field fails a test.
export function extractRawLayout(action: ActionForm): ForestServerActionFormLayoutElement[] {
Expand Down Expand Up @@ -70,7 +77,7 @@ export default function createAgentActionClient({
});

return {
loadAction: (collection, action, recordIds) =>
client.collection(collection).action(action, { recordIds }),
loadAction: ({ collection, actionName, recordIds, timezone }) =>
client.collection(collection).action(actionName, { recordIds, timezone }),
};
}
71 changes: 65 additions & 6 deletions packages/agent-bff/test/action/action-routes-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,15 @@ function buildApp(
{
agentToken = 'agent-jwt',
logger = noopLogger,
}: { agentToken?: string | null; logger?: Logger } = {},
timezone = TIMEZONE,
}: { agentToken?: string | null; logger?: Logger; timezone?: string } = {},
) {
const app = new Koa();
app.silent = true;
app.use(createErrorMiddleware({ logger: noopLogger }));
app.use(bodyParser());
app.use(async (ctx, next) => {
ctx.state.timezone = TIMEZONE;
ctx.state.timezone = timezone;
if (agentToken !== null) ctx.state.agentToken = agentToken;
await next();
});
Expand Down Expand Up @@ -301,7 +302,12 @@ describe('action routes middleware', () => {
.post('/agent/v1/users/actions/approve/form')
.send({ recordIds: ['1|2'] });

expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['1|2']);
expect(loadAction).toHaveBeenCalledWith({
collection: 'users',
actionName: 'approve',
recordIds: ['1|2'],
timezone: TIMEZONE,
});
});

it('coerces a numeric zero recordId to a string so it survives the downstream filter', async () => {
Expand All @@ -313,7 +319,12 @@ describe('action routes middleware', () => {
.post('/agent/v1/users/actions/approve/form')
.send({ recordIds: [0] });

expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['0']);
expect(loadAction).toHaveBeenCalledWith({
collection: 'users',
actionName: 'approve',
recordIds: ['0'],
timezone: TIMEZONE,
});
});

it('accepts an empty recordIds array for a global action', async () => {
Expand All @@ -326,7 +337,31 @@ describe('action routes middleware', () => {
.send({ recordIds: [] });

expect(response.status).toBe(200);
expect(loadAction).toHaveBeenCalledWith('users', 'approve', []);
expect(loadAction).toHaveBeenCalledWith({
collection: 'users',
actionName: 'approve',
recordIds: [],
timezone: TIMEZONE,
});
});

it('forwards the request-resolved timezone to the action form load', async () => {
const form = makeAction();
const loadAction = jest.fn(async () => form);
const app = buildApp(storeOf(readModel), clientOf(form, loadAction), {
timezone: 'America/New_York',
});

await request(app.callback())
.post('/agent/v1/users/actions/approve/form')
.send({ recordIds: ['42'] });

expect(loadAction).toHaveBeenCalledWith({
collection: 'users',
actionName: 'approve',
recordIds: ['42'],
timezone: 'America/New_York',
});
});

it('returns 400 invalid_request with no agent call when recordIds is missing', async () => {
Expand Down Expand Up @@ -473,14 +508,38 @@ describe('action execute', () => {
.post('/agent/v1/users/actions/approve/execute')
.send({ recordIds: ['42'], values: { reason: 'x' } });

expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['42']);
expect(loadAction).toHaveBeenCalledWith({
collection: 'users',
actionName: 'approve',
recordIds: ['42'],
timezone: TIMEZONE,
});
expect(setFields).toHaveBeenCalledWith({ reason: 'x' });
expect(execute).toHaveBeenCalledTimes(1);
// Order is the behaviour named by this test: executing before the values are applied would run
// the action on an empty form.
expect(setFields.mock.invocationCallOrder[0]).toBeLessThan(execute.mock.invocationCallOrder[0]);
});

it('forwards the request-resolved timezone to the action execute load', async () => {
const form = makeAction();
const loadAction = jest.fn(async () => form);
const app = buildApp(storeOf(readModel), clientOf(form, loadAction), {
timezone: 'Asia/Tokyo',
});

await request(app.callback())
.post('/agent/v1/users/actions/approve/execute')
.send({ recordIds: ['42'] });

expect(loadAction).toHaveBeenCalledWith({
collection: 'users',
actionName: 'approve',
recordIds: ['42'],
timezone: 'Asia/Tokyo',
});
});

it('normalizes a Success result to 200 with invalidated as an array', async () => {
const form = makeAction({
execute: jest.fn(async () => ({
Expand Down
14 changes: 11 additions & 3 deletions packages/agent-bff/test/action/agent-action-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('createAgentActionClient', () => {
mockedHttpRequester.mockImplementation(() => ({ query, stream } as unknown as HttpRequester));
});

it('loads the action via createRemoteAgentClient().collection(name).action(name, { recordIds })', async () => {
it('loads the action via createRemoteAgentClient().collection(name).action(name, { recordIds, timezone })', async () => {
const loadedAction = { tag: 'action' };
const actionFn = jest.fn(async () => loadedAction);
const collectionFn = jest.fn(() => ({ action: actionFn }));
Expand All @@ -30,7 +30,12 @@ describe('createAgentActionClient', () => {
token: 'jwt-token',
actionEndpoints,
});
const result = await client.loadAction('users', 'approve', ['1', '2']);
const result = await client.loadAction({
collection: 'users',
actionName: 'approve',
recordIds: ['1', '2'],
timezone: 'America/New_York',
});

expect(HttpRequester).toHaveBeenCalledWith('jwt-token', { url: 'https://agent.example.com' });
expect(createRemoteAgentClientMock).toHaveBeenCalledWith({
Expand All @@ -40,7 +45,10 @@ describe('createAgentActionClient', () => {
httpRequester: expect.objectContaining({ query }),
});
expect(collectionFn).toHaveBeenCalledWith('users');
expect(actionFn).toHaveBeenCalledWith('approve', { recordIds: ['1', '2'] });
expect(actionFn).toHaveBeenCalledWith('approve', {
recordIds: ['1', '2'],
timezone: 'America/New_York',
});
expect(result).toBe(loadedAction);
});

Expand Down
4 changes: 2 additions & 2 deletions packages/agent-client/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ Entry point is `createRemoteAgentClient(...)` in `src/index.ts`, which wires an

**Two things flow through everything:**

1. **`HttpRequester`** (`http-requester.ts`) is the only thing that touches the network (superagent). It bearer-auths, always sends `timezone=Europe/Paris`, JSON:API-deserializes responses into camelCase (falling back to raw body/text), and on HTTP errors throws a typed `AgentHttpError(status, body, responseText)`. Network/timeout errors (no response) propagate raw.
1. **`HttpRequester`** (`http-requester.ts`) is the only thing that touches the network (superagent). It bearer-auths, defaults the query to `timezone=Europe/Paris` (a caller-supplied `query.timezone` overrides it), JSON:API-deserializes responses into camelCase (falling back to raw body/text), and on HTTP errors throws a typed `AgentHttpError(status, body, responseText)`. Network/timeout errors (no response) propagate raw.
2. **`QuerySerializer`** (`query-serializer.ts`) turns a `SelectOptions` (`types.ts`) into the agent's query-string shape (`fields[collection]`, `page[size]`, JSON-stringified `filters`/`sort`).

**Smart actions** are the most stateful part. `Collection.action()` looks up the action's endpoint in the `ActionEndpointsByCollection` schema, then `FieldFormStates` (`action-fields/field-form-states.ts`) POSTs to `:endpoint/hooks/load` to fetch the dynamic form; `setFields` re-POSTs to `:endpoint/hooks/change` to re-evaluate dependent fields. Concrete `ActionField*` classes (`action-fields/`) are typed accessors over those form states; `getField` dispatches on the field type string. `Action.execute()` POSTs the collected values, and `toActionError` (`domains/action.ts`) translates `AgentHttpError` into semantic `ActionRequiresApprovalError` (403) / `ActionFormValidationError` (400/422).
**Smart actions** are the most stateful part. `Collection.action()` looks up the action's endpoint in the `ActionEndpointsByCollection` schema, then `FieldFormStates` (`action-fields/field-form-states.ts`) POSTs to `:endpoint/hooks/load` to fetch the dynamic form; `setFields` re-POSTs to `:endpoint/hooks/change` to re-evaluate dependent fields. Both hook calls carry `timezone` in the query when the action context supplied one, so date-dependent form defaults resolve in the caller's timezone instead of the `Europe/Paris` default; `Action.execute()` still uses the default. Concrete `ActionField*` classes (`action-fields/`) are typed accessors over those form states; `getField` dispatches on the field type string. `Action.execute()` POSTs the collected values, and `toActionError` (`domains/action.ts`) translates `AgentHttpError` into semantic `ActionRequiresApprovalError` (403) / `ActionFormValidationError` (400/422).

Depends on `@forestadmin/datasource-toolkit` (for `PlainFilter`/`PlainSortClause`/chart types) and `@forestadmin/forestadmin-client` (for `ForestSchemaAction` and condition-tree types).

Expand Down
43 changes: 32 additions & 11 deletions packages/agent-client/src/action-fields/field-form-states.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import ActionFieldMultipleChoice from './action-field-multiple-choice';
import FieldGetter from './field-getter';
import encodeFileFieldValue from './file-value';

export interface FieldFormStatesOptions {
actionName: string;
actionPath: string;
collectionName: string;
httpRequester: HttpRequester;
ids: string[];
hooks?: ForestSchemaAction['hooks'];
fallbackFields?: ForestSchemaAction['fields'];
fallbackLayout?: ForestSchemaAction['layout'];
timezone?: string;
}

export default class FieldFormStates {
private readonly fields: FieldGetter[];
private readonly actionName: string;
Expand All @@ -20,17 +32,19 @@ export default class FieldFormStates {
private readonly hooks?: ForestSchemaAction['hooks'];
private readonly fallbackFields?: ForestSchemaAction['fields'];
private readonly fallbackLayout?: ForestSchemaAction['layout'];

constructor(
actionName: string,
actionPath: string,
collectionName: string,
httpRequester: HttpRequester,
ids: string[],
hooks?: ForestSchemaAction['hooks'],
fallbackFields?: ForestSchemaAction['fields'],
fallbackLayout?: ForestSchemaAction['layout'],
) {
private readonly timezone?: string;

constructor({
actionName,
actionPath,
collectionName,
httpRequester,
ids,
hooks,
fallbackFields,
fallbackLayout,
timezone,
}: FieldFormStatesOptions) {
this.fields = [];
this.actionName = actionName;
this.actionPath = actionPath;
Expand All @@ -41,6 +55,7 @@ export default class FieldFormStates {
this.hooks = hooks;
this.fallbackFields = fallbackFields;
this.fallbackLayout = fallbackLayout;
this.timezone = timezone;
}

getFieldValues(): Record<string, unknown> {
Expand Down Expand Up @@ -108,6 +123,7 @@ export default class FieldFormStates {
method: 'post',
path: `${this.actionPath}/hooks/load`,
body: requestBody,
query: this.buildTimezoneQuery(),
});

this.clearFieldsAndLayout();
Expand Down Expand Up @@ -147,6 +163,10 @@ export default class FieldFormStates {
}
}

private buildTimezoneQuery(): { timezone: string } | undefined {
return this.timezone ? { timezone: this.timezone } : undefined;
}

private addFields(plainFields: PlainField[]): void {
plainFields.forEach(f => this.fields.push(new FieldGetter(f)));
}
Expand All @@ -173,6 +193,7 @@ export default class FieldFormStates {
method: 'post',
path: `${this.actionPath}/hooks/change`,
body: requestBody,
query: this.buildTimezoneQuery(),
});

this.clearFieldsAndLayout();
Expand Down
1 change: 1 addition & 0 deletions packages/agent-client/src/domains/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function toActionError(error: unknown): unknown {
export type BaseActionContext = {
recordId?: RecordId;
recordIds?: RecordId[];
timezone?: string;
};

export type ActionExecuteOptions = {
Expand Down
Loading
Loading