Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 845 lines 22 kB view raw
1/* eslint-disable max-lines */ 2import { AuthenticationError } from 'apollo-server-express'; 3 4import { isConditionSet } from '../../condition_evaluator/condition.js'; 5import { 6 getNameForDerivedField, 7 type DerivedFieldSpec as TDerivedFieldSpec, 8} from '../../services/derivedFieldsService/index.js'; 9import { 10 CoopInput, 11 type ConditionInput, 12 type LeafCondition, 13} from '../../services/moderationConfigService/index.js'; 14import { isSignalId } from '../../services/signalsService/index.js'; 15import { jsonParse } from '../../utils/encoding.js'; 16import { isCoopErrorOfType } from '../../utils/errors.js'; 17import { assertUnreachable } from '../../utils/misc.js'; 18import { 19 type GQLConditionResolvers, 20 type GQLConditionWithResultResolvers, 21 type GQLContentRuleResolvers, 22 type GQLLeafConditionResolvers, 23 type GQLLeafConditionWithResultResolvers, 24 type GQLMutationResolvers, 25 type GQLQueryResolvers, 26 type GQLRuleResolvers, 27 type GQLUserRuleResolvers, 28} from '../generated.js'; 29import { type Context, type ResolverMap } from '../resolvers.js'; 30import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; 31 32const typeDefs = /* GraphQL */ ` 33 type Query { 34 rule(id: ID!): Rule 35 } 36 37 type Mutation { 38 createContentRule( 39 input: CreateContentRuleInput! 40 ): CreateContentRuleResponse! 41 updateContentRule( 42 input: UpdateContentRuleInput! 43 ): UpdateContentRuleResponse! 44 createUserRule(input: CreateUserRuleInput!): CreateUserRuleResponse! 45 updateUserRule(input: UpdateUserRuleInput!): UpdateUserRuleResponse! 46 deleteRule(id: ID!): Boolean 47 } 48 49 interface Rule { 50 id: ID! 51 parentId: ID 52 name: String! 53 creator: User! 54 createdAt: String! 55 updatedAt: String! 56 description: String 57 status: RuleStatus! 58 conditionSet: ConditionSet! 59 actions: [Action!]! 60 policies: [Policy!]! 61 tags: [String] 62 # GraphQL doesn't support BIGINT, so this must be a Float 63 maxDailyActions: Float 64 expirationTime: String 65 backtests(ids: [ID!]): [Backtest!]! 66 insights: RuleInsights! 67 } 68 69 type ContentRule implements Rule { 70 id: ID! 71 parentId: ID 72 name: String! 73 creator: User! 74 createdAt: String! 75 updatedAt: String! 76 description: String 77 status: RuleStatus! 78 conditionSet: ConditionSet! 79 actions: [Action!]! 80 policies: [Policy!]! 81 tags: [String] 82 maxDailyActions: Float 83 expirationTime: String 84 backtests(ids: [ID!]): [Backtest!]! 85 insights: RuleInsights! 86 # Content rule specific fields 87 itemTypes: [ItemType!]! 88 } 89 90 type UserRule implements Rule { 91 id: ID! 92 parentId: ID 93 name: String! 94 creator: User! 95 createdAt: String! 96 updatedAt: String! 97 description: String 98 status: RuleStatus! 99 conditionSet: ConditionSet! 100 actions: [Action!]! 101 policies: [Policy!]! 102 tags: [String] 103 maxDailyActions: Float 104 expirationTime: String 105 backtests(ids: [ID!]): [Backtest!]! 106 insights: RuleInsights! 107 } 108 109 # ConditionSetWithResult holds all the info we need to display the rule's 110 # conditions, and each of their results, on a single piece of content. 111 # It's identical to the backend TS type ConditionSetWithResultAsLogged, 112 # because it only has the subset of the fields that we bother to store in 113 # the data warehouse about a rule execution. 114 type ConditionSetWithResult { 115 conjunction: ConditionConjunction 116 conditions: [ConditionWithResult!]! 117 # If undefined, it means the condition set was skipped. 118 # OR, the condition was run before we started logging results. 119 result: ConditionResult 120 } 121 122 union ConditionWithResult = LeafConditionWithResult | ConditionSetWithResult 123 124 type LeafConditionWithResult { 125 input: ConditionInputField! 126 signal: Signal 127 matchingValues: MatchingValues 128 comparator: ValueComparator 129 threshold: StringOrFloat 130 # If undefined, it means the condition was skipped. 131 result: ConditionResult 132 } 133 134 enum RuleStatus { 135 BACKGROUND 136 DRAFT 137 DEPRECATED 138 EXPIRED 139 LIVE 140 ARCHIVED 141 } 142 143 enum ConditionConjunction { 144 AND 145 OR 146 XOR 147 } 148 149 type ConditionSet { 150 conjunction: ConditionConjunction! 151 conditions: [Condition!]! 152 } 153 154 union Condition = ConditionSet | LeafCondition 155 156 type LeafCondition { 157 input: ConditionInputField! 158 signal: Signal 159 matchingValues: MatchingValues 160 comparator: ValueComparator 161 threshold: StringOrFloat 162 } 163 164 # Ideally, this should be a union (mirroring ConditionInput on the backend), 165 # but Gql doesn't support union input types, so this is easier. 166 # !! IMPORTANT: Keep this in sync with ConditionInputFieldInput 167 type ConditionInputField { 168 type: ConditionInputInputType! 169 name: CoopInputOrString 170 contentTypeId: String 171 contentTypeIds: [String!] 172 spec: DerivedFieldSpec 173 } 174 175 enum ConditionInputInputType { 176 USER_ID 177 CONTENT_FIELD 178 CONTENT_COOP_INPUT 179 FULL_ITEM 180 CONTENT_DERIVED_FIELD 181 } 182 183 type MatchingValues { 184 strings: [String!] 185 textBankIds: [String!] 186 locations: [LocationArea!] 187 locationBankIds: [String!] 188 imageBankIds: [String!] 189 } 190 191 type DerivedFieldSpec { 192 source: DerivedFieldSource! 193 derivationType: DerivedFieldDerivationType! 194 } 195 196 input DerivedFieldSpecInput { 197 source: DerivedFieldSourceInput! 198 derivationType: DerivedFieldDerivationType! 199 } 200 201 enum DerivedFieldDerivationType { 202 ENGLISH_TRANSLATION 203 VIDEO_TRANSCRIPTION 204 } 205 206 union DerivedFieldSource = 207 DerivedFieldFullItemSource 208 | DerivedFieldFieldSource 209 | DerivedFieldCoopInputSource 210 211 # NB: This is a more cumbersome approach compared to what we've used 212 # previously to handle input union types (e.g., in ConditionInputField). 213 # However, it is more type safe and is forward compatible with 214 # https://github.com/graphql/graphql-spec/pull/825, which is nice. 215 input DerivedFieldSourceInput { 216 fullItem: DerivedFieldFullItemSourceInput 217 contentField: DerivedFieldFieldSourceInput 218 contentCoopInput: DerivedFieldCoopInputSourceInput 219 } 220 221 # Re the _ field, see https://github.com/graphql/graphql-spec/issues/568 and 222 # https://github.com/graphql/graphql-spec/pull/825#issuecomment-1182979316 223 type DerivedFieldFullItemSource { 224 _: Boolean 225 } 226 input DerivedFieldFullItemSourceInput { 227 _: Boolean 228 } 229 230 type DerivedFieldFieldSource { 231 name: String! 232 contentTypeId: String! 233 } 234 input DerivedFieldFieldSourceInput { 235 name: String! 236 contentTypeId: String! 237 } 238 239 type DerivedFieldCoopInputSource { 240 name: CoopInput! 241 } 242 input DerivedFieldCoopInputSourceInput { 243 name: CoopInput! 244 } 245 246 enum CoopInput { 247 ALL_TEXT 248 ANY_IMAGE 249 ANY_GEOHASH 250 ANY_VIDEO 251 AUTHOR_USER 252 POLICY_ID 253 SOURCE 254 } 255 256 input ConditionMatchingValuesInput { 257 strings: [String!] 258 textBankIds: [String!] 259 locations: [LocationAreaInput!] 260 locationBankIds: [String!] 261 imageBankIds: [String!] 262 } 263 264 # Allows user to see the evaluation result of each condition 265 type ConditionResult { 266 outcome: ConditionOutcome! 267 268 # Represents computed results from a single condition - e.g. 269 # 3P model scores, similarity scores, bool evaluations, etc. 270 # It has to be a String because there is no concept of the 'any' 271 # type (or even union types like string | number) in GraphQL. So 272 # no matter what the score type actually is, we pass it to the client 273 # as a string 274 score: String 275 276 # Represents the value on which the input was matched against for 277 # signals that require matching values. For example, this would be 278 # the matched regex in a regex bank that triggered the rule. 279 matchedValue: String 280 } 281 282 enum ConditionOutcome { 283 PASSED 284 FAILED 285 INAPPLICABLE 286 ERRORED 287 } 288 289 input CreateContentRuleInput { 290 name: String! 291 description: String 292 status: RuleStatus! 293 contentTypeIds: [ID!]! 294 conditionSet: ConditionSetInput! 295 actionIds: [ID!]! 296 policyIds: [ID!]! 297 tags: [String!]! 298 # GraphQL doesn't support BIGINT, so this must be a Float 299 maxDailyActions: Float 300 expirationTime: DateTime 301 parentId: ID 302 } 303 304 input UpdateContentRuleInput { 305 id: ID! 306 name: String 307 description: String 308 status: RuleStatus 309 contentTypeIds: [ID!] 310 conditionSet: ConditionSetInput 311 actionIds: [ID!] 312 policyIds: [ID!] 313 tags: [String!] 314 # GraphQL doesn't support BIGINT, so this must be a Float 315 maxDailyActions: Float 316 expirationTime: DateTime 317 cancelRunningBacktests: Boolean 318 parentId: ID 319 } 320 321 input CreateUserRuleInput { 322 name: String! 323 description: String 324 status: RuleStatus! 325 conditionSet: ConditionSetInput! 326 actionIds: [ID!]! 327 policyIds: [ID!]! 328 tags: [String!]! 329 # GraphQL doesn't support BIGINT, so this must be a Float 330 maxDailyActions: Float 331 expirationTime: DateTime 332 parentId: ID 333 } 334 335 input UpdateUserRuleInput { 336 id: ID! 337 name: String 338 description: String 339 status: RuleStatus 340 conditionSet: ConditionSetInput 341 actionIds: [ID!] 342 policyIds: [ID!] 343 tags: [String!] 344 # GraphQL doesn't support BIGINT, so this must be a Float 345 maxDailyActions: Float 346 expirationTime: DateTime 347 cancelRunningBacktests: Boolean 348 parentId: ID 349 } 350 351 input ConditionSetInput { 352 conjunction: ConditionConjunction! 353 conditions: [ConditionInput!]! 354 } 355 356 # This needs to be able to represent a ConditionSet or a LeafCondition, 357 # but Graphql union input type support is WIP (https://github.com/graphql/graphql-spec/pull/825), 358 # so we have fields here for both. 359 input ConditionInput { 360 # LeafCondition fields 361 input: ConditionInputFieldInput 362 signal: ConditionInputSignalInput 363 matchingValues: ConditionMatchingValuesInput 364 comparator: ValueComparator 365 threshold: StringOrFloat 366 367 # ConditionSet fields 368 conjunction: ConditionConjunction 369 conditions: [ConditionInput!] 370 } 371 372 input ConditionInputSignalInput { 373 id: ID! # JsonOf<SignalId> 374 type: String! 375 name: String 376 subcategory: String 377 args: SignalArgsInput 378 } 379 380 input SignalSubcategoryInput { 381 name: String 382 options: [SignalSubcategoryOptionInput] 383 } 384 385 input SignalSubcategoryOptionInput { 386 name: String 387 description: String 388 } 389 390 input DisabledInfoInput { 391 disabled: Boolean 392 disabledMessage: String 393 } 394 395 # Ideally, this should be a union (mirroring ConditionInput on the backend), 396 # but Gql doesn't support union input types, so this is easier. 397 # 398 # !! IMPORTANT: Keep this in sync with ConditionInputField 399 input ConditionInputFieldInput { 400 type: ConditionInputInputType! 401 name: CoopInputOrString 402 contentTypeId: String 403 contentTypeIds: [String!] 404 spec: DerivedFieldSpecInput 405 } 406 407 type RuleNameExistsError implements Error { 408 title: String! 409 status: Int! 410 type: [String!]! 411 pointer: String 412 detail: String 413 requestId: String 414 } 415 416 input SignalArgsInput { 417 AGGREGATION: AggregationSignalArgsInput 418 } 419 420 input AggregationSignalArgsInput { 421 aggregationClause: AggregationClauseInput! 422 } 423 424 input AggregationClauseInput { 425 aggregation: AggregationInput! 426 conditionSet: ConditionSetInput 427 groupBy: [ConditionInputFieldInput!]! 428 window: WindowConfigurationInput! 429 } 430 431 input WindowConfigurationInput { 432 sizeMs: Int! 433 hopMs: Int! 434 } 435 436 input AggregationInput { 437 type: AggregationType! 438 } 439 440 441 442 type RuleHasRunningBacktestsError implements Error { 443 title: String! 444 status: Int! 445 type: [String!]! 446 pointer: String 447 detail: String 448 requestId: String 449 } 450 451 union CreateContentRuleResponse = 452 MutateContentRuleSuccessResponse 453 | RuleNameExistsError 454 455 union UpdateContentRuleResponse = 456 MutateContentRuleSuccessResponse 457 | RuleNameExistsError 458 | RuleHasRunningBacktestsError 459 | NotFoundError 460 461 union CreateUserRuleResponse = 462 MutateUserRuleSuccessResponse 463 | RuleNameExistsError 464 465 union UpdateUserRuleResponse = 466 MutateUserRuleSuccessResponse 467 | RuleNameExistsError 468 | RuleHasRunningBacktestsError 469 | NotFoundError 470 471 type MutateContentRuleSuccessResponse { 472 data: ContentRule! 473 } 474 475 type MutateUserRuleSuccessResponse { 476 data: UserRule! 477 } 478`; 479 480const Query: GQLQueryResolvers = { 481 async rule(_, { id }, { dataSources, getUser }) { 482 const user = await getUser(); 483 if (user == null) { 484 throw new AuthenticationError('Authenticated user required'); 485 } 486 487 return dataSources.ruleAPI.getGraphQLRuleFromId(id, user.orgId); 488 }, 489}; 490 491const Mutation: GQLMutationResolvers = { 492 async createContentRule(_, params, context) { 493 const user = context.getUser(); 494 if (user == null) { 495 throw new AuthenticationError('Authenticated user required'); 496 } 497 498 try { 499 const rule = await context.dataSources.ruleAPI.createContentRule( 500 params.input, 501 user.id, 502 user.orgId, 503 ); 504 505 return gqlSuccessResult( 506 { data: rule }, 507 'MutateContentRuleSuccessResponse', 508 ); 509 } catch (e: unknown) { 510 if (isCoopErrorOfType(e, 'RuleNameExistsError')) { 511 return gqlErrorResult(e, `/input/name`); 512 } 513 514 throw e; 515 } 516 }, 517 async updateContentRule(_, params, context) { 518 try { 519 const user = context.getUser(); 520 if (user == null) { 521 throw new AuthenticationError('Authenticated user required'); 522 } 523 524 const rule = await context.dataSources.ruleAPI.updateContentRule({ 525 input: params.input, 526 orgId: user.orgId, 527 }); 528 return gqlSuccessResult( 529 { data: rule }, 530 'MutateContentRuleSuccessResponse', 531 ); 532 } catch (e: unknown) { 533 if (isCoopErrorOfType(e, 'RuleNameExistsError')) { 534 return gqlErrorResult(e, `/input/name`); 535 } 536 537 if (isCoopErrorOfType(e, 'RuleHasRunningBacktestsError')) { 538 return gqlErrorResult(e, `/input/cancelRunningBacktests`); 539 } 540 541 if (isCoopErrorOfType(e, 'NotFoundError')) { 542 return gqlErrorResult(e, `/input/id`); 543 } 544 545 throw e; 546 } 547 }, 548 async createUserRule(_, params, context) { 549 const user = context.getUser(); 550 if (user == null) { 551 throw new AuthenticationError('Authenticated user required'); 552 } 553 554 try { 555 const rule = await context.dataSources.ruleAPI.createUserRule( 556 params.input, 557 user.id, 558 user.orgId, 559 ); 560 561 return gqlSuccessResult({ data: rule }, 'MutateUserRuleSuccessResponse'); 562 } catch (e: unknown) { 563 if (isCoopErrorOfType(e, 'RuleNameExistsError')) { 564 return gqlErrorResult(e, `/input/name`); 565 } 566 567 throw e; 568 } 569 }, 570 async updateUserRule(_, params, context) { 571 const user = context.getUser(); 572 if (user == null) { 573 throw new AuthenticationError('Authenticated user required'); 574 } 575 576 try { 577 const rule = await context.dataSources.ruleAPI.updateUserRule({ 578 input: params.input, 579 orgId: user.orgId, 580 }); 581 return gqlSuccessResult({ data: rule }, 'MutateUserRuleSuccessResponse'); 582 } catch (e: unknown) { 583 if (isCoopErrorOfType(e, 'RuleNameExistsError')) { 584 return gqlErrorResult(e, `/input/name`); 585 } 586 587 if (isCoopErrorOfType(e, 'RuleHasRunningBacktestsError')) { 588 return gqlErrorResult(e, `/input/cancelRunningBacktests`); 589 } 590 591 if (isCoopErrorOfType(e, 'NotFoundError')) { 592 return gqlErrorResult(e, `/input/id`); 593 } 594 595 throw e; 596 } 597 }, 598 async deleteRule(_, params, context) { 599 const user = context.getUser(); 600 if (user == null) { 601 throw new AuthenticationError('Authenticated user required'); 602 } 603 604 return context.dataSources.ruleAPI.deleteRule({ 605 id: params.id, 606 orgId: user.orgId, 607 }); 608 }, 609}; 610 611const ConditionInputField: ResolverMap<ConditionInput> = { 612 async name(conditionInputField, _, context) { 613 const user = context.getUser(); 614 if (user == null) { 615 throw new AuthenticationError('Authenticated user required'); 616 } 617 618 if (conditionInputField.type !== 'CONTENT_DERIVED_FIELD') { 619 return (conditionInputField as { name?: string }).name ?? null; 620 } else { 621 return getNameForDerivedField(conditionInputField.spec); 622 } 623 }, 624}; 625 626const Rule: GQLRuleResolvers = { 627 async __resolveType(rule) { 628 switch (rule.ruleType) { 629 case 'CONTENT': 630 return 'ContentRule'; 631 case 'USER': 632 return 'UserRule'; 633 default: 634 assertUnreachable(rule.ruleType); 635 } 636 }, 637}; 638 639const ContentRule: GQLContentRuleResolvers = { 640 async creator(rule, _, context) { 641 const user = context.getUser(); 642 if (user == null) { 643 throw new AuthenticationError('Authenticated user required'); 644 } 645 646 return rule.getCreator(); 647 }, 648 async itemTypes(rule, _, { services, getUser }) { 649 const user = getUser(); 650 if (user == null) { 651 throw new AuthenticationError('Authenticated user required'); 652 } 653 return services.ModerationConfigService.getItemTypesForRule({ 654 orgId: user.orgId, 655 ruleId: rule.id, 656 }); 657 }, 658 async actions(rule, _, context) { 659 const user = context.getUser(); 660 if (user == null) { 661 throw new AuthenticationError('Authenticated user required'); 662 } 663 664 return rule.getActions(); 665 }, 666 async policies(rule, _, context) { 667 const user = context.getUser(); 668 if (user == null) { 669 throw new AuthenticationError('Authenticated user required'); 670 } 671 672 return rule.getPolicies(); 673 }, 674 async backtests(rule, args, context) { 675 const user = context.getUser(); 676 if (user == null) { 677 throw new AuthenticationError('Authenticated user required'); 678 } 679 680 const { ids } = args; 681 return context.dataSources.ruleAPI.getBacktestsForRule(rule.id, ids); 682 }, 683 async insights(rule, _args, context) { 684 // just return the rule, which then becomes the parent/source for the 685 // insights resolver. But verify the rule is owned by the user's org 686 const user = context.getUser(); 687 if (user == null) { 688 throw new AuthenticationError('User required'); 689 } 690 691 if (rule.orgId !== user.orgId) { 692 throw new Error("Rule does not belong to user's org"); 693 } 694 695 return rule; 696 }, 697}; 698 699const UserRule: GQLUserRuleResolvers = { 700 async creator(rule, _, context) { 701 const user = context.getUser(); 702 if (user == null) { 703 throw new AuthenticationError('Authenticated user required'); 704 } 705 706 return rule.getCreator(); 707 }, 708 async actions(rule, _, context) { 709 const user = context.getUser(); 710 if (user == null) { 711 throw new AuthenticationError('Authenticated user required'); 712 } 713 714 return rule.getActions(); 715 }, 716 async policies(rule, _, context) { 717 const user = context.getUser(); 718 if (user == null) { 719 throw new AuthenticationError('Authenticated user required'); 720 } 721 722 return rule.getPolicies(); 723 }, 724 async backtests(rule, args, context) { 725 const user = context.getUser(); 726 if (user == null) { 727 throw new AuthenticationError('Authenticated user required'); 728 } 729 730 const { ids } = args; 731 return context.dataSources.ruleAPI.getBacktestsForRule(rule.id, ids); 732 }, 733 async insights(rule, _args, context) { 734 // just return the rule, which then becomes the parent/source for the 735 // insights resolver. But verify the rule is owned by the user's org 736 const user = context.getUser(); 737 if (user == null) { 738 throw new AuthenticationError('Authenticated user required'); 739 } 740 741 if (rule.orgId !== user.orgId) { 742 throw new Error("Rule does not belong to user's org"); 743 } 744 745 return rule; 746 }, 747}; 748 749const Condition: GQLConditionResolvers = { 750 __resolveType(condition) { 751 return isConditionSet(condition) ? 'ConditionSet' : 'LeafCondition'; 752 }, 753}; 754 755const ConditionWithResult: GQLConditionWithResultResolvers = { 756 __resolveType(conditionWithResult) { 757 return 'conditions' in conditionWithResult 758 ? 'ConditionSetWithResult' 759 : 'LeafConditionWithResult'; 760 }, 761}; 762 763const signalForStoredCondition = async ( 764 condition: Pick<LeafCondition, 'signal'>, 765 _args: unknown, 766 context: Context, 767) => { 768 const user = context.getUser(); 769 if (!condition.signal || !user) return null; 770 771 const signalId = jsonParse(condition.signal.id); 772 773 // Handle case of a stored signal id that's no longer valid (e.g., cuz the 774 // underlying signal's been deleted.) 775 if (!isSignalId(signalId)) return null; 776 777 const signal = await context.services.SignalsService.getSignal({ 778 signalId, 779 // TODO: In the context of an individual condition, it's pretty hard 780 // to get the org that owns the rule that contains the condition. We 781 // should make that easier (how?) but, for now, we can just assume 782 // that the rule is owned by the same org as the user. 783 orgId: user.orgId, 784 }); 785 if (!signal) { 786 return null; 787 } 788 789 return { 790 ...signal, 791 subcategory: condition.signal.subcategory ?? undefined, 792 args: condition.signal.args, 793 }; 794}; 795 796const LeafCondition: GQLLeafConditionResolvers = { 797 signal: signalForStoredCondition, 798}; 799 800const LeafConditionWithResult: GQLLeafConditionWithResultResolvers = { 801 signal: signalForStoredCondition, 802}; 803 804const DerivedFieldSource: ResolverMap<TDerivedFieldSpec['source']> = { 805 __resolveType(derivedFieldSource) { 806 const { type } = derivedFieldSource; 807 switch (type) { 808 case 'FULL_ITEM': 809 return 'DerivedFieldFullItemSource'; 810 case 'CONTENT_FIELD': 811 return 'DerivedFieldFieldSource'; 812 case 'CONTENT_COOP_INPUT': 813 return 'DerivedFieldCoopInputSource'; 814 default: 815 assertUnreachable(type); 816 } 817 }, 818}; 819 820const resolvers = { 821 Query, 822 Mutation, 823 Rule, 824 ContentRule, 825 UserRule, 826 Condition, 827 LeafCondition, 828 ConditionWithResult, 829 LeafConditionWithResult, 830 DerivedFieldSource, 831 ConditionInputField, 832 // Use the CoopInput enum directly as the resolver, to map the incoming GQL 833 // names, which happen to match the TS enum case names, to the underlying string 834 // values. 835 // 836 // NB: In some places, the GQL API outputs what's internally a CoopInput, 837 // but the GQL Schema doesn't yet declare it as such. Accordingly, those values 838 // won't go through this resolver, and will end up inconsistent (making their 839 // way to the frontend as the internal TS value, rather than as the enum case). 840 // That's ok, because it's not a backwards compatibility break; we'll just 841 // gradually update the schema to actually use the enum. 842 CoopInput, 843}; 844 845export { typeDefs, resolvers };