Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Sanction Check Creation & Configuration #674

Draft
wants to merge 3 commits into
base: sanction-check
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion packages/app-builder/src/components/Nudge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const Nudge = ({
<Hovercard
portal
gutter={8}
className="bg-grey-100 border-purple-82 flex w-60 flex-col items-center gap-6 rounded border p-4 shadow-lg"
className="bg-grey-100 border-purple-82 z-50 flex w-60 flex-col items-center gap-6 rounded border p-4 shadow-lg"
>
<span className="text-m font-bold">{t('common:premium')}</span>
<div className="flex w-full flex-col items-center gap-2">
Expand Down
12 changes: 11 additions & 1 deletion packages/app-builder/src/locales/en/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,17 @@
"create_scenario.trigger_object_title": "Trigger Object",
"trigger_object.description": "The central object that initiates the decision-making process. Provided as a payload when requesting a decision.",
"update_scenario.title": "Edit Scenario",
"create_rule.title": "New Rule",
"sanction.nudge": "Improve your rules with a sanction check based on OpenSacntion API",
"create_sanction.title": "Add a sanction check",
"create_sanction.description": "Add a sanction check to the scenario",
"create_sanction.default_name": "Sanction Check",
"edit_sanction.global_settings": "Global settings sanction check",
"edit_sanction.global_settings.callout": "These settings will be saved in your global company settings and will be applied for all your scenario.",
"edit_sanction.similarity_score": "Minimum similarity score (%)",
"edit_sanction.trigger_title": "Trigger conditions",
"edit_sanction.trigger.callout": "Determines whether the sanction check is relevant for each trigger object (<DocLink>learn more</DocLink>)",
"create_rule.title": "Add a Rule",
"create_rule.description": "Add a rule to the scenario",
"create_rule.default_name": "New rule",
"clone_rule.button": "Clone",
"clone_rule.title": "Clone this rule",
Expand Down
26 changes: 25 additions & 1 deletion packages/app-builder/src/models/organization.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
import { type OrganizationDto } from 'marble-api';

import { type KnownOutcome } from './outcome';

export interface Organization {
id: string;
name: string;
defaultScenarioTimezone: string | null;
sanctionCheck: {
forcedOutcome: Omit<KnownOutcome, 'approve'>;
similarityScore: number;
};
}

export const adaptOrganizationDto = (
organizationDto: OrganizationDto,
organizationDto: OrganizationDto & {
sanction_check: {
forced_outcome: Organization['sanctionCheck']['forcedOutcome'];
similarity_score: number;
};
},
): Organization => ({
id: organizationDto.id,
name: organizationDto.name,
defaultScenarioTimezone: organizationDto.default_scenario_timezone
? organizationDto.default_scenario_timezone
: null,
sanctionCheck: {
forcedOutcome: organizationDto.sanction_check
.forced_outcome as KnownOutcome,
similarityScore: organizationDto.sanction_check.similarity_score,
},
});

export interface OrganizationUpdateInput {
defaultScenarioTimezone?: string;
sanctionCheck?: {
forcedOutcome?: Omit<KnownOutcome, 'approve'>;
similarityScore?: number;
};
}
30 changes: 30 additions & 0 deletions packages/app-builder/src/models/scenario-iteration-sanction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type AstNode } from './astNode/ast-node';

export interface ScenarioIterationSanction {
id: string;
scenarioIterationId: string;
displayOrder: number;
name: string;
description: string;
ruleGroup: string;
formula: AstNode | null;
createdAt: string;
}

export interface CreateScenarioIterationSanctionInput {
scenarioIterationId: string;
displayOrder: number;
name: string;
description: string;
ruleGroup: string;
formula: AstNode | null;
}

export interface UpdateScenarioIterationSanctionInput {
sanctionId: string;
displayOrder?: number;
name?: string;
description?: string;
ruleGroup?: string;
formula?: AstNode | null;
}
40 changes: 33 additions & 7 deletions packages/app-builder/src/repositories/OrganizationRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { adaptUser, type User } from '@app-builder/models';
import {
adaptOrganizationDto,
type Organization,
type OrganizationUpdateInput,
} from '@app-builder/models/organization';
import { type Tag } from 'marble-api';

Expand All @@ -12,11 +13,16 @@ export interface OrganizationRepository {
listTags(args?: { withCaseCount: boolean }): Promise<Tag[]>;
updateOrganization(args: {
organizationId: string;
defaultScenarioTimezone: string;
changes: OrganizationUpdateInput;
}): Promise<Organization>;
}

export function makeGetOrganizationRepository() {
const sanctionCheckSettings: Organization['sanctionCheck'] = {
forcedOutcome: 'block_and_review',
similarityScore: 60,
};

return (
marbleCoreApiClient: MarbleCoreApi,
organizationId: string,
Expand All @@ -25,7 +31,13 @@ export function makeGetOrganizationRepository() {
const { organization } =
await marbleCoreApiClient.getOrganization(organizationId);

return adaptOrganizationDto(organization);
return adaptOrganizationDto({
...organization,
sanction_check: {
forced_outcome: sanctionCheckSettings.forcedOutcome,
similarity_score: sanctionCheckSettings.similarityScore,
},
});
},
listUsers: async () => {
const { users } =
Expand All @@ -38,11 +50,25 @@ export function makeGetOrganizationRepository() {
return tags;
},
updateOrganization: async (args) => {
const { organization: updatedOrganization } =
await marbleCoreApiClient.updateOrganization(organizationId, {
default_scenario_timezone: args.defaultScenarioTimezone,
});
return adaptOrganizationDto(updatedOrganization);
const { organization } = await marbleCoreApiClient.updateOrganization(
organizationId,
{
default_scenario_timezone: args.changes.defaultScenarioTimezone,
},
);
if (args.changes.sanctionCheck?.forcedOutcome)
sanctionCheckSettings.forcedOutcome =
args.changes.sanctionCheck.forcedOutcome;
if (args.changes.sanctionCheck?.similarityScore)
sanctionCheckSettings.similarityScore =
args.changes.sanctionCheck.similarityScore;
return adaptOrganizationDto({
...organization,
sanction_check: {
forced_outcome: sanctionCheckSettings.forcedOutcome,
similarity_score: sanctionCheckSettings.similarityScore,
},
});
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { type MarbleCoreApi } from '@app-builder/infra/marblecore-api';
import {
type CreateScenarioIterationSanctionInput,
type ScenarioIterationSanction,
type UpdateScenarioIterationSanctionInput,
} from '@app-builder/models/scenario-iteration-sanction';

export interface ScenarioIterationSanctionRepository {
listSanctions(args: {
scenarioIterationId: string;
}): Promise<ScenarioIterationSanction[]>;
getSanction(args: { sanctionId: string }): Promise<ScenarioIterationSanction>;
createSanction(
args: CreateScenarioIterationSanctionInput,
): Promise<ScenarioIterationSanction>;
updateSanction(
args: UpdateScenarioIterationSanctionInput,
): Promise<ScenarioIterationSanction>;
deleteSanction(args: { sanctionId: string }): Promise<void>;
}

export function makeGetScenarioIterationSanctionRepository() {
const sanctions: ScenarioIterationSanction[] = [];

return (_: MarbleCoreApi): ScenarioIterationSanctionRepository => ({
listSanctions: async ({ scenarioIterationId }) => {
return Promise.resolve(
sanctions.filter((s) => s.scenarioIterationId === scenarioIterationId),
);
},
getSanction: async ({ sanctionId }) => {
const sanction = sanctions.find((s) => s.id === sanctionId);
return sanction
? Promise.resolve(sanction)
: Promise.reject(new Error('Sanction not found'));
},
createSanction: async (args) => {
const sanction = {
id: args.scenarioIterationId,
scenarioIterationId: args.scenarioIterationId,
displayOrder: args.displayOrder,
name: args.name,
description: args.description,
ruleGroup: args.ruleGroup,
formula: args.formula,
createdAt: new Date().toISOString(),
};
sanctions.push(sanction);
return Promise.resolve(sanction);
},
updateSanction: async (args) => {
const sanction = sanctions.find((s) => s.id === args.sanctionId);
if (!sanction) {
return Promise.reject(new Error('Sanction not found'));
}
if (args.name !== undefined) {
sanction.name = args.name;
}
if (args.description !== undefined) {
sanction.description = args.description;
}
if (args.ruleGroup !== undefined) {
sanction.ruleGroup = args.ruleGroup;
}
if (args.formula !== undefined) {
sanction.formula = args.formula;
}
return Promise.resolve(sanction);
},
deleteSanction: async ({ sanctionId }) => {
const sanction = sanctions.find((s) => s.id === sanctionId);
if (!sanction) {
return Promise.reject(new Error('Sanction not found'));
}
sanctions.splice(sanctions.indexOf(sanction), 1);
return Promise.resolve();
},
});
}
3 changes: 3 additions & 0 deletions packages/app-builder/src/repositories/init.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { makeGetOrganizationRepository } from './OrganizationRepository';
import { makeGetPartnerRepository } from './PartnerRepository';
import { makeGetRuleSnoozeRepository } from './RuleSnoozeRepository';
import { makeGetScenarioIterationRuleRepository } from './ScenarioIterationRuleRepository';
import { makeGetScenarioIterationSanctionRepository } from './ScenarioIterationSanctionRepository';
import { makeGetScenarioRepository } from './ScenarioRepository';
import {
getAuthStorageRepository,
Expand Down Expand Up @@ -71,6 +72,8 @@ export function makeServerRepositories({
getScenarioRepository: makeGetScenarioRepository(),
getScenarioIterationRuleRepository:
makeGetScenarioIterationRuleRepository(),
getScenarioIterationSanctionRepository:
makeGetScenarioIterationSanctionRepository(),
getOrganizationRepository: makeGetOrganizationRepository(),
getDataModelRepository: makeGetDataModelRepository(),
getApiKeyRepository: makeGetApiKeyRepository(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { navigationI18n, Page, TabLink } from '@app-builder/components';
import { BreadCrumbs } from '@app-builder/components/Breadcrumbs';
import { CornerPing } from '@app-builder/components/Ping';
import {
getFormattedLive,
getFormattedVersion,
ScenarioIterationMenu,
} from '@app-builder/components/Scenario/Iteration/ScenarioIterationMenu';
import { type ScenarioIterationWithType } from '@app-builder/models/scenario-iteration';
import {
useCurrentScenario,
useScenarioIterations,
Expand All @@ -27,46 +21,21 @@ import {
hasRulesErrors,
hasTriggerErrors,
} from '@app-builder/services/validation';
import {
formatDateRelative,
useFormatLanguage,
} from '@app-builder/utils/format';
import { getRoute } from '@app-builder/utils/routes';
import { fromParams, fromUUID, useParam } from '@app-builder/utils/short-uuid';
import { fromParams, useParam } from '@app-builder/utils/short-uuid';
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { Outlet, useLoaderData, useLocation } from '@remix-run/react';
import { Outlet, useLoaderData } from '@remix-run/react';
import { type Namespace } from 'i18next';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import invariant from 'tiny-invariant';
import { MenuButton, Tag } from 'ui-design-system';
import { Tag } from 'ui-design-system';
import { Icon } from 'ui-icons';

import { useCurrentScenarioValidation } from '../_layout';

export const handle = {
i18n: [...navigationI18n, 'scenarios', 'common'] satisfies Namespace,
BreadCrumbs: [
() => {
const scenarioIterations = useScenarioIterations();
const iterationId = useParam('iterationId');

const currentIteration = React.useMemo(() => {
const currentIteration = scenarioIterations.find(
({ id }) => id === iterationId,
);
invariant(currentIteration, 'currentIteration is required');
return currentIteration;
}, [iterationId, scenarioIterations]);

return (
<VersionSelect
currentIteration={currentIteration}
scenarioIterations={scenarioIterations}
/>
);
},
],
};

export async function loader({ request, params }: LoaderFunctionArgs) {
Expand Down Expand Up @@ -225,65 +194,6 @@ export default function ScenarioEditLayout() {
);
}

function VersionSelect({
currentIteration,
scenarioIterations,
}: {
currentIteration: ScenarioIterationWithType;
scenarioIterations: ScenarioIterationWithType[];
}) {
const { t } = useTranslation(['scenarios']);
const location = useLocation();
const language = useFormatLanguage();

const labelledScenarioIteration = React.useMemo(
() =>
scenarioIterations.map((si) => ({
id: si.id,
type: si.type,
version: si.version,
updatedAt: si.updatedAt,
linkTo: location.pathname.replace(
fromUUID(currentIteration.id),
fromUUID(si.id),
),
formattedVersion: getFormattedVersion(si, t),
formattedLive: getFormattedLive(si, t),
formattedUpdatedAt: formatDateRelative(si.updatedAt, {
language,
}),
})),
[currentIteration.id, language, location.pathname, scenarioIterations, t],
);

const currentFormattedVersion = getFormattedVersion(currentIteration, t);
const currentFormattedLive = getFormattedLive(currentIteration, t);

return (
<ScenarioIterationMenu
labelledScenarioIteration={labelledScenarioIteration}
>
<MenuButton className="text-s text-grey-00 border-grey-90 focus:border-purple-65 flex min-h-10 items-center justify-between rounded-full border p-2 font-medium outline-none">
<p className="text-s ml-2 flex flex-row gap-1 font-semibold">
<span className="text-grey-00 capitalize">
{currentFormattedVersion}
</span>
{currentFormattedLive ? (
<span className="text-purple-65 capitalize">
{currentFormattedLive}
</span>
) : null}
</p>
<Icon
aria-hidden
icon="arrow-2-down"
className="text-grey-00 size-6 shrink-0"
/>
</MenuButton>
</ScenarioIterationMenu>
);
}

function ScenariosLinkIcon({
withPing,
...props
Expand Down
Loading
Loading