diff --git a/CHANGELOG.md b/CHANGELOG.md index bc13d7c8b..1268cb4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ For more information about each release including git tags and artifacts, see [R ### Added +- View and edit policy penalty severity in the Policies dashboard and policy form ([#1248](https://github.com/roostorg/coop/pull/1248) by [@jess-upscrolled](https://github.com/jess-upscrolled)) - List parameters used in the action in the item action history view ([#1173](https://github.com/roostorg/coop/pull/1173) by [@maarkN](https://github.com/maarkN), closes [#833](https://github.com/roostorg/coop/issues/833)) - Manual Review Analytics with average handle time per moderator ([#1022](https://github.com/roostorg/coop/pull/1022) by [@juanmrad](https://github.com/juanmrad), closes [#380](https://github.com/roostorg/coop/issues/380)) - Support for text-only NCMEC reports ([#866](https://github.com/roostorg/coop/pull/866), [#881](https://github.com/roostorg/coop/pull/881) by [@calebmcquaid](https://github.com/calebmcquaid), closes [#661](https://github.com/roostorg/coop/issues/661)) diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index c3ac37ae6..71757553f 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -250,6 +250,7 @@ export type GQLAddPolicyInput = { readonly name: Scalars['String']['input']; readonly parentId?: InputMaybe; readonly parentName?: InputMaybe; + readonly penalty?: InputMaybe; readonly policyText?: InputMaybe; readonly policyType?: InputMaybe; }; @@ -3417,6 +3418,7 @@ export type GQLPolicy = { readonly id: Scalars['ID']['output']; readonly name: Scalars['String']['output']; readonly parentId?: Maybe; + readonly penalty: GQLUserPenaltySeverity; readonly policyText?: Maybe; readonly policyType?: Maybe; readonly userStrikeCount?: Maybe; @@ -4893,6 +4895,7 @@ export type GQLUpdatePolicyInput = { readonly id: Scalars['ID']['input']; readonly name: Scalars['String']['input']; readonly parentId?: InputMaybe; + readonly penalty?: InputMaybe; readonly policyText?: InputMaybe; readonly policyType?: InputMaybe; readonly userStrikeCount?: InputMaybe; @@ -19201,6 +19204,7 @@ export type GQLPolicyFieldsFragment = { readonly policyType?: GQLPolicyType | null; readonly userStrikeCount?: number | null; readonly applyUserStrikeCountConfigToChildren?: boolean | null; + readonly penalty: GQLUserPenaltySeverity; }; export type GQLPoliciesQueryVariables = Exact<{ [key: string]: never }>; @@ -19219,6 +19223,7 @@ export type GQLPoliciesQuery = { readonly policyType?: GQLPolicyType | null; readonly userStrikeCount?: number | null; readonly applyUserStrikeCountConfigToChildren?: boolean | null; + readonly penalty: GQLUserPenaltySeverity; }>; } | null; }; @@ -19241,6 +19246,7 @@ export type GQLPoliciesWithModelsQuery = { readonly policyType?: GQLPolicyType | null; readonly userStrikeCount?: number | null; readonly applyUserStrikeCountConfigToChildren?: boolean | null; + readonly penalty: GQLUserPenaltySeverity; }>; } | null; readonly me?: { @@ -19268,6 +19274,7 @@ export type GQLAddPoliciesMutation = { readonly policyType?: GQLPolicyType | null; readonly userStrikeCount?: number | null; readonly applyUserStrikeCountConfigToChildren?: boolean | null; + readonly penalty: GQLUserPenaltySeverity; }>; }; }; @@ -19290,6 +19297,7 @@ export type GQLUpdatePolicyMutation = { readonly policyType?: GQLPolicyType | null; readonly userStrikeCount?: number | null; readonly applyUserStrikeCountConfigToChildren?: boolean | null; + readonly penalty: GQLUserPenaltySeverity; }; }; @@ -25914,6 +25922,7 @@ export const GQLPolicyFieldsFragmentDoc = gql` policyType userStrikeCount applyUserStrikeCountConfigToChildren + penalty } `; export const GQLRulesDashboardRuleFieldsFragmentFragmentDoc = gql` diff --git a/client/src/webpages/dashboard/policies/PoliciesDashboard.tsx b/client/src/webpages/dashboard/policies/PoliciesDashboard.tsx index 1b3b1c768..d8ebdb0ce 100644 --- a/client/src/webpages/dashboard/policies/PoliciesDashboard.tsx +++ b/client/src/webpages/dashboard/policies/PoliciesDashboard.tsx @@ -25,7 +25,9 @@ import { useGQLPoliciesWithModelsQuery, } from '../../../graphql/generated'; import { userHasPermissions } from '../../../routing/permissions'; +import { titleCaseEnumString } from '../../../utils/string'; import { Tree, treeFromList, TreeNode } from '../../../utils/tree'; +import { getSeverityColor } from '../../../utils/userPenalty'; import { ModalInfo } from '../types/ModalInfo'; export type Policy = { @@ -52,6 +54,7 @@ gql` policyType userStrikeCount applyUserStrikeCountConfigToChildren + penalty } query Policies { @@ -219,6 +222,13 @@ export default function PoliciesDashboard() {
{policy.value?.name}
+
+ {titleCaseEnumString(policy.value.penalty)} +
ID:
diff --git a/client/src/webpages/dashboard/policies/PolicyForm.tsx b/client/src/webpages/dashboard/policies/PolicyForm.tsx index 8b4b56606..7286a2a3a 100644 --- a/client/src/webpages/dashboard/policies/PolicyForm.tsx +++ b/client/src/webpages/dashboard/policies/PolicyForm.tsx @@ -1,6 +1,6 @@ import { Button } from '@/coop-ui/Button'; import { treeFromList } from '@/utils/tree'; -import { Input } from 'antd'; +import { Input, Select } from 'antd'; import { Check as CheckmarkFilled, Plus as PlusFilled, @@ -21,9 +21,13 @@ import { useGQLUpdatePolicyMutation, type GQLPolicy, } from '../../../graphql/generated'; +import { titleCaseEnumString } from '../../../utils/string'; +import { UserPenaltySeverityOrder } from '../../../utils/userPenalty'; import MarkdownTextInput from './MarkdownTextInput'; import { Policy } from './PoliciesDashboard'; +const { Option } = Select; + export type PolicyInputModalInfo = { onClose: () => void; existingPolicy?: Policy; @@ -39,6 +43,9 @@ export default function PolicyForm() { const navigate = useNavigate(); const [policyName, setPolicyName] = useState(undefined); const [policyText, setPolicyText] = useState(undefined); + const [policyPenalty, setPolicyPenalty] = useState( + GQLUserPenaltySeverity.None, + ); const [parent, setParent] = useState< { id: string; name: string } | undefined >(undefined); @@ -51,7 +58,10 @@ export default function PolicyForm() { const [showEnforcementGuidelines, setShowEnforcementGuidelines] = useState(false); const [existingPolicy, setExistingPolicy] = useState< - | Pick + | Pick< + GQLPolicy, + 'id' | 'name' | 'enforcementGuidelines' | 'policyText' | 'penalty' + > | undefined >(undefined); const { data, loading } = useGQLPoliciesQuery(); @@ -85,6 +95,7 @@ export default function PolicyForm() { if (existingPolicy) { setPolicyName(existingPolicy.name); setPolicyText(existingPolicy.policyText ?? undefined); + setPolicyPenalty(existingPolicy.penalty); setEnforcementGuidelines( existingPolicy.enforcementGuidelines ?? undefined, ); @@ -138,6 +149,21 @@ export default function PolicyForm() { onChange={(event) => setPolicyName(event.target.value)} /> +
+
Penalty
+ +
); @@ -228,6 +254,7 @@ export default function PolicyForm() { existingPolicy.enforcementGuidelines ?? undefined, ); setPolicyText(existingPolicy.policyText ?? undefined); + setPolicyPenalty(existingPolicy.penalty); }} > @@ -253,6 +280,7 @@ export default function PolicyForm() { enforcementGuidelines, name: policyName, parentId: parent?.id ?? undefined, + penalty: policyPenalty, }, }, onCompleted: () => setShowSuccess(true), @@ -268,6 +296,7 @@ export default function PolicyForm() { enforcementGuidelines, name: policyName, parentId: parent?.id ?? undefined, + penalty: policyPenalty, }, ], }, diff --git a/docs/user/administration.md b/docs/user/administration.md index e060921f3..40cc41a95 100644 --- a/docs/user/administration.md +++ b/docs/user/administration.md @@ -70,6 +70,8 @@ Policies are the set of rules and guidelines that a platform uses to govern the ![Policy Dashboard showing 4 policies: Fraud, Nudity, Scams, Spam. There's a button to create new policies as well as options to add sub-policies for each existing policy and edit or delete them](../images/policies.png) +When you create or edit a policy, you can set its **Penalty** to one of: **None**, **Low**, **Medium**, **High**, or **Severe**. The Policies dashboard shows each policy's penalty next to its name. Penalty is included with the policy in [action webhook payloads](../api/actions.md) so your platform can weigh how serious a given policy violation is. + Policies added in Coop's UI are visible to reviewers directly in the [Job view](review-console.md#job-view) of the Review Console. ## User management diff --git a/docs/user/concepts.md b/docs/user/concepts.md index a5264a289..516e0ba3c 100644 --- a/docs/user/concepts.md +++ b/docs/user/concepts.md @@ -76,6 +76,8 @@ Policies can have sub-policies; for example, a _Spam_ policy could have sub-poli It is often useful (and in some cases required, i.e. by the EU's Digital Services Act) to tie every Action you take to one or more specific Policies. For example, you could _Delete_ a comment under your _Hate Speech_ policy, or you could _Delete_ it under your _Spam_ policy. Coop allows you to track those differences and measure how many Actions you've taken for each Policy. That way, you can see how effectively you're enforcing each Policy over time, identify Policies for which your enforcement is poor or degrading, and report performance metrics to your leadership (or to regulators, i.e. in the form of a DSA Transparency Report). +Each Policy also has a **Penalty** severity (**None**, **Low**, **Medium**, **High**, or **Severe**) that you set when creating or editing the policy. Penalty appears on the Policies dashboard and is sent with the policy in [action webhook payloads](../api/actions.md), so your platform can treat violations of more serious policies differently from milder ones. + You can create and manage your Policies in the **Policies** dashboard, and you can fetch them programmatically through the [Policies API](../api/policies.md). Policies added in Coop's UI are also visible to reviewers directly in the [Job view](review-console.md#job-view) of the Review Console. ## Jobs diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index d425b62d8..944f2e609 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -318,6 +318,7 @@ export type GQLAddPolicyInput = { readonly name: Scalars['String']['input']; readonly parentId?: InputMaybe; readonly parentName?: InputMaybe; + readonly penalty?: InputMaybe; readonly policyText?: InputMaybe; readonly policyType?: InputMaybe; }; @@ -3485,6 +3486,7 @@ export type GQLPolicy = { readonly id: Scalars['ID']['output']; readonly name: Scalars['String']['output']; readonly parentId?: Maybe; + readonly penalty: GQLUserPenaltySeverity; readonly policyText?: Maybe; readonly policyType?: Maybe; readonly userStrikeCount?: Maybe; @@ -4961,6 +4963,7 @@ export type GQLUpdatePolicyInput = { readonly id: Scalars['ID']['input']; readonly name: Scalars['String']['input']; readonly parentId?: InputMaybe; + readonly penalty?: InputMaybe; readonly policyText?: InputMaybe; readonly policyType?: InputMaybe; readonly userStrikeCount?: InputMaybe; @@ -12443,6 +12446,11 @@ export type GQLPolicyResolvers< id?: Resolver; name?: Resolver; parentId?: Resolver, ParentType, ContextType>; + penalty?: Resolver< + GQLResolversTypes['UserPenaltySeverity'], + ParentType, + ContextType + >; policyText?: Resolver< Maybe, ParentType, diff --git a/server/graphql/modules/policy.ts b/server/graphql/modules/policy.ts index 49f7c5b79..5194f61ad 100644 --- a/server/graphql/modules/policy.ts +++ b/server/graphql/modules/policy.ts @@ -24,6 +24,7 @@ const typeDefs = /* GraphQL */ ` policyType: PolicyType userStrikeCount: Int applyUserStrikeCountConfigToChildren: Boolean + penalty: UserPenaltySeverity! } enum PolicyType { @@ -55,6 +56,7 @@ const typeDefs = /* GraphQL */ ` parentId: ID parentName: String policyType: PolicyType + penalty: UserPenaltySeverity } input UpdatePolicyInput { @@ -66,6 +68,7 @@ const typeDefs = /* GraphQL */ ` policyType: PolicyType userStrikeCount: Int applyUserStrikeCountConfigToChildren: Boolean + penalty: UserPenaltySeverity } type Mutation { @@ -127,6 +130,7 @@ const Mutation: GQLMutationResolvers = { policyText: policy.policyText ?? null, enforcementGuidelines: policy.enforcementGuidelines ?? null, policyType: policy.policyType ?? null, + penalty: policy.penalty ?? null, }, orgId: user.orgId, invokedBy: { @@ -172,6 +176,7 @@ const Mutation: GQLMutationResolvers = { policyType, userStrikeCount, applyUserStrikeCountConfigToChildren, + penalty, } = input; const updatedPolicy = @@ -185,6 +190,7 @@ const Mutation: GQLMutationResolvers = { enforcementGuidelines, userStrikeCount, applyUserStrikeCountConfigToChildren, + penalty, }, orgId: user.orgId, invokedBy: { diff --git a/server/services/moderationConfigService/moderationConfigService.test.ts b/server/services/moderationConfigService/moderationConfigService.test.ts index 5e98adb37..c8d8ac3b3 100644 --- a/server/services/moderationConfigService/moderationConfigService.test.ts +++ b/server/services/moderationConfigService/moderationConfigService.test.ts @@ -20,6 +20,7 @@ import { type ModerationConfigServicePg } from './dbTypes.js'; import { RuleStatus, RuleType, + UserPenaltySeverity, type ConditionSet, type Policy, } from './index.js'; @@ -1486,6 +1487,87 @@ describe('ModerationConfigService', () => { }, ); + testWithUserAndOrg( + 'should default omitted penalty to NONE on create', + async ({ sutWithPrimary, org, user }) => { + const created = await sutWithPrimary.createPolicy({ + orgId: org.id, + policy: { + name: 'Default Penalty Policy', + policyText: 'Policy text', + enforcementGuidelines: null, + policyType: null, + parentId: null, + }, + invokedBy: { + orgId: org.id, + userId: user.id, + permissions: user.getPermissions(), + }, + }); + + expect(created.penalty).toEqual(UserPenaltySeverity.NONE); + + const fetched = await sutWithPrimary.getPolicies({ orgId: org.id }); + expect(fetched).toHaveLength(1); + expect(fetched[0].penalty).toEqual(UserPenaltySeverity.NONE); + }, + ); + + testWithUserAndOrg( + 'should persist and update policy penalty', + async ({ sutWithPrimary, org, user }) => { + const invokedBy = { + orgId: org.id, + userId: user.id, + permissions: user.getPermissions(), + }; + + const created = await sutWithPrimary.createPolicy({ + orgId: org.id, + policy: { + name: 'Penalty Policy', + policyText: 'Policy text', + enforcementGuidelines: null, + policyType: null, + parentId: null, + penalty: UserPenaltySeverity.HIGH, + }, + invokedBy, + }); + + expect(created.penalty).toEqual(UserPenaltySeverity.HIGH); + + const updated = await sutWithPrimary.updatePolicy({ + orgId: org.id, + policy: { + id: created.id, + name: created.name, + penalty: UserPenaltySeverity.SEVERE, + }, + invokedBy, + }); + + expect(updated.penalty).toEqual(UserPenaltySeverity.SEVERE); + + const cleared = await sutWithPrimary.updatePolicy({ + orgId: org.id, + policy: { + id: created.id, + name: created.name, + penalty: UserPenaltySeverity.NONE, + }, + invokedBy, + }); + + expect(cleared.penalty).toEqual(UserPenaltySeverity.NONE); + + const fetched = await sutWithPrimary.getPolicies({ orgId: org.id }); + expect(fetched).toHaveLength(1); + expect(fetched[0].penalty).toEqual(UserPenaltySeverity.NONE); + }, + ); + testWithUserAndOrg( 'Prevent creation of policy with the same name as an existing policy', async ({ sutWithPrimary, org, user }) => { diff --git a/server/services/moderationConfigService/moderationConfigService.ts b/server/services/moderationConfigService/moderationConfigService.ts index e099af372..2c642d59b 100644 --- a/server/services/moderationConfigService/moderationConfigService.ts +++ b/server/services/moderationConfigService/moderationConfigService.ts @@ -32,6 +32,7 @@ import { } from './types/itemTypes.js'; import type { PolicyType } from './types/policies.js'; import { type PlainRuleWithLatestVersion } from './types/rules.js'; +import type { UserPenaltySeverity } from './types/shared.js'; export type ModerationConfigErrorType = | 'AttemptingToDeleteDefaultUserType' @@ -354,6 +355,7 @@ export class ModerationConfigService implements ReturnsModerationConfigTypes { policyText: string | null; enforcementGuidelines: string | null; policyType: PolicyType | null; + penalty?: UserPenaltySeverity | null; }; invokedBy: Invoker; }): Promise { @@ -371,6 +373,7 @@ export class ModerationConfigService implements ReturnsModerationConfigTypes { policyType?: PolicyType | null; userStrikeCount?: number | null; applyUserStrikeCountConfigToChildren?: boolean | null; + penalty?: UserPenaltySeverity | null; }; invokedBy: Invoker; }): Promise { diff --git a/server/services/moderationConfigService/modules/PolicyOperations.ts b/server/services/moderationConfigService/modules/PolicyOperations.ts index 6fc3ff671..d591346e2 100644 --- a/server/services/moderationConfigService/modules/PolicyOperations.ts +++ b/server/services/moderationConfigService/modules/PolicyOperations.ts @@ -20,6 +20,7 @@ import { import { type ModerationConfigServicePg } from '../dbTypes.js'; import { type Policy } from '../index.js'; import type { PolicyType } from '../types/policies.js'; +import type { UserPenaltySeverity } from '../types/shared.js'; const policyDbSelection = [ 'id', @@ -154,6 +155,7 @@ export default class PolicyOperations { policyText?: string | null; enforcementGuidelines?: string | null; policyType?: PolicyType | null; + penalty?: UserPenaltySeverity | null; }; invokedBy: Invoker; }) { @@ -164,6 +166,7 @@ export default class PolicyOperations { policyText: policy_text, enforcementGuidelines: enforcement_guidelines, policyType: policy_type, + penalty, } = policy; if (!invokedBy.permissions.includes(UserPermission.MANAGE_POLICIES)) { throw makeUnauthorizedError( @@ -180,7 +183,7 @@ export default class PolicyOperations { name, org_id, parent_id, - penalty: 'NONE', + penalty: penalty ?? 'NONE', policy_text, enforcement_guidelines, policy_type, @@ -209,6 +212,7 @@ export default class PolicyOperations { policyType?: PolicyType | null; userStrikeCount?: number | null; applyUserStrikeCountConfigToChildren?: boolean | null; + penalty?: UserPenaltySeverity | null; }; invokedBy: Invoker; }) { @@ -233,6 +237,7 @@ export default class PolicyOperations { user_strike_count: policy.userStrikeCount ?? undefined, apply_user_strike_count_config_to_children: policy.applyUserStrikeCountConfigToChildren ?? undefined, + penalty: policy.penalty ?? undefined, updated_at: new Date(), }), )