Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

feat: add moderation system with labels, reports, and user preferences

Labels:
- Label definitions with severity levels (takedown, alert, inform)
- Apply/negate labels on records and accounts
- Automatic takedown filtering from all queries
- Self-labels support (author-applied labels merged with moderator labels)
- Labels field exposed on all record types

Reports:
- User-submitted reports with reason types
- Admin review and resolution workflow
- Connection pagination for admin queries

Label Preferences:
- Per-user visibility settings (ignore, show, warn, hide)
- Query and mutation via public /graphql endpoint
- System labels enforced, cannot be overridden

Admin API:
- Connection types with cursor pagination for labels and reports
- Label definition management
- Report resolution with label application

Also includes:
- Union input type support for GraphQL mutations
- Moderation documentation guide

+1449
dev-docs/plans/2025-12-29-generated-union-input-types.md
··· 1 + # Generated Union Input Types Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Generate type-safe GraphQL input types for AT Protocol union fields, with proper discriminator handling for both single and multi-variant unions, and transformation to AT Protocol format. 6 + 7 + **Architecture:** Build input types from lexicon ObjectDefs at schema build time. For single-variant unions, use the variant's input type directly. For multi-variant unions, generate a discriminated union input with a `type` enum field and optional fields for each variant. Build a registry mapping field paths to their union refs during schema generation, pass it to the mutation context, and use it for dynamic `$type` resolution. 8 + 9 + **Tech Stack:** Gleam, swell GraphQL library, lexicon_graphql type mapper, lexicon registry 10 + 11 + --- 12 + 13 + ## Background 14 + 15 + AT Protocol unions require a `$type` discriminator in JSON: 16 + ```json 17 + { 18 + "$type": "com.atproto.label.defs#selfLabels", 19 + "values": [{ "val": "sexual" }] 20 + } 21 + ``` 22 + 23 + GraphQL can't use `$type` (reserved character), so we: 24 + 1. Generate input types matching the union variant structure 25 + 2. For single-variant unions: use the variant type directly 26 + 3. For multi-variant unions: add a `type` enum field as discriminator 27 + 4. Build a registry of union field -> refs during schema generation 28 + 5. Transform server-side using the registry to add the correct `$type` field 29 + 30 + **Key Design: Dynamic Union Resolution** 31 + 32 + Since Quickslice works with arbitrary lexicons, we can't hardcode union variants. Instead: 33 + - During schema generation, build a `UnionFieldRegistry`: `Dict(String, List(String))` 34 + - Key: `"collection.fieldName"` (e.g., `"social.grain.gallery.labels"`) 35 + - Value: list of variant refs (e.g., `["com.atproto.label.defs#selfLabels"]`) 36 + - Pass this registry to mutation resolvers 37 + - Use it at transformation time to look up the correct `$type` 38 + 39 + **Example schema for single-variant union (labels):** 40 + ```graphql 41 + input SelfLabelInput { 42 + val: String! 43 + } 44 + 45 + input SelfLabelsInput { 46 + values: [SelfLabelInput!]! 47 + } 48 + 49 + input SocialGrainGalleryInput { 50 + title: String! 51 + images: [BlobInput!]! 52 + labels: SelfLabelsInput # Direct variant type 53 + } 54 + ``` 55 + 56 + **Example schema for multi-variant union (embed):** 57 + ```graphql 58 + enum AppBskyFeedPostEmbedType { 59 + IMAGES 60 + EXTERNAL 61 + RECORD 62 + RECORD_WITH_MEDIA 63 + } 64 + 65 + input AppBskyFeedPostEmbedInput { 66 + type: AppBskyFeedPostEmbedType! # Discriminator 67 + images: AppBskyEmbedImagesInput 68 + external: AppBskyEmbedExternalInput 69 + record: AppBskyEmbedRecordInput 70 + recordWithMedia: AppBskyEmbedRecordWithMediaInput 71 + } 72 + ``` 73 + 74 + --- 75 + 76 + ### Task 1: Create Union Input Builder Module 77 + 78 + **Files:** 79 + - Create: `lexicon_graphql/src/lexicon_graphql/internal/graphql/union_input_builder.gleam` 80 + 81 + **Step 1: Create the union input builder module** 82 + 83 + ```gleam 84 + /// Union Input Builder 85 + /// 86 + /// Builds GraphQL input types for AT Protocol union fields. 87 + /// Generates input types from lexicon ObjectDefs, handling nested refs. 88 + /// 89 + /// For single-variant unions: returns the variant's input type directly 90 + /// For multi-variant unions: creates a discriminated input type with: 91 + /// - A `type` enum field for selecting the variant 92 + /// - Optional fields for each variant's data 93 + /// 94 + /// Also builds a UnionFieldRegistry for dynamic $type resolution at runtime. 95 + import gleam/dict.{type Dict} 96 + import gleam/list 97 + import gleam/option.{type Option} 98 + import gleam/string 99 + import lexicon_graphql/internal/graphql/type_mapper 100 + import lexicon_graphql/internal/lexicon/nsid 101 + import lexicon_graphql/internal/lexicon/registry as lexicon_registry 102 + import lexicon_graphql/types 103 + import swell/schema 104 + 105 + /// Registry of generated union input types 106 + /// Maps fully-qualified ref (e.g., "com.atproto.label.defs#selfLabels") to input type 107 + pub type UnionInputRegistry = 108 + Dict(String, schema.Type) 109 + 110 + /// Mapping of union field paths to their variant refs 111 + /// Key: "collection.fieldName" (e.g., "social.grain.gallery.labels") 112 + /// Value: list of variant refs (e.g., ["com.atproto.label.defs#selfLabels"]) 113 + /// Used for dynamic $type resolution during mutation transformation 114 + pub type UnionFieldRegistry = 115 + Dict(String, List(String)) 116 + 117 + /// Combined registry for union inputs 118 + pub type UnionRegistry { 119 + UnionRegistry( 120 + input_types: UnionInputRegistry, 121 + field_variants: UnionFieldRegistry, 122 + ) 123 + } 124 + 125 + /// Build input types for all object defs in the registry 126 + /// Returns a registry with input types (field_variants populated separately) 127 + pub fn build_union_input_types( 128 + registry: lexicon_registry.Registry, 129 + ) -> UnionRegistry { 130 + let object_defs = lexicon_registry.get_all_object_defs(registry) 131 + 132 + // Build input types in dependency order (simple types first) 133 + // Do two passes: first build all without refs, then with refs 134 + let first_pass = 135 + dict.fold(object_defs, dict.new(), fn(acc, ref, obj_def) { 136 + let input_type = build_input_type_from_object_def(ref, obj_def, acc) 137 + dict.insert(acc, ref, input_type) 138 + }) 139 + 140 + // Second pass to resolve any remaining refs 141 + let input_types = 142 + dict.fold(object_defs, first_pass, fn(acc, ref, obj_def) { 143 + let input_type = build_input_type_from_object_def(ref, obj_def, acc) 144 + dict.insert(acc, ref, input_type) 145 + }) 146 + 147 + // field_variants is populated during build_input_type in mutation builder 148 + UnionRegistry(input_types: input_types, field_variants: dict.new()) 149 + } 150 + 151 + /// Add a union field entry to the registry 152 + pub fn register_union_field( 153 + registry: UnionRegistry, 154 + collection: String, 155 + field_name: String, 156 + refs: List(String), 157 + ) -> UnionRegistry { 158 + let key = collection <> "." <> field_name 159 + UnionRegistry( 160 + input_types: registry.input_types, 161 + field_variants: dict.insert(registry.field_variants, key, refs), 162 + ) 163 + } 164 + 165 + /// Look up union refs for a field 166 + pub fn get_union_refs( 167 + registry: UnionRegistry, 168 + collection: String, 169 + field_name: String, 170 + ) -> Option(List(String)) { 171 + let key = collection <> "." <> field_name 172 + dict.get(registry.field_variants, key) |> option.from_result 173 + } 174 + 175 + /// Convert a ref like "com.atproto.label.defs#selfLabels" to "SelfLabelsInput" 176 + pub fn ref_to_input_type_name(ref: String) -> String { 177 + let base_name = nsid.to_type_name(string.replace(ref, "#", ".")) 178 + base_name <> "Input" 179 + } 180 + 181 + /// Convert a ref to a short variant name for enum values 182 + /// "com.atproto.label.defs#selfLabels" -> "SELF_LABELS" 183 + pub fn ref_to_variant_enum_value(ref: String) -> String { 184 + // Extract the part after # or the last segment 185 + let short_name = case string.split(ref, "#") { 186 + [_, name] -> name 187 + _ -> { 188 + case string.split(ref, ".") |> list.last { 189 + Ok(name) -> name 190 + Error(_) -> ref 191 + } 192 + } 193 + } 194 + // Convert camelCase to SCREAMING_SNAKE_CASE 195 + camel_to_screaming_snake(short_name) 196 + } 197 + 198 + /// Convert camelCase to SCREAMING_SNAKE_CASE 199 + fn camel_to_screaming_snake(s: String) -> String { 200 + s 201 + |> string.to_graphemes 202 + |> list.fold(#("", False), fn(acc, char) { 203 + let #(result, prev_was_lower) = acc 204 + let is_upper = string.uppercase(char) == char && string.lowercase(char) != char 205 + case is_upper, prev_was_lower { 206 + True, True -> #(result <> "_" <> char, False) 207 + _, _ -> #(result <> string.uppercase(char), !is_upper) 208 + } 209 + }) 210 + |> fn(pair) { pair.0 } 211 + } 212 + 213 + /// Convert SCREAMING_SNAKE_CASE back to the original short name 214 + /// "SELF_LABELS" -> "selfLabels" 215 + pub fn enum_value_to_short_name(enum_value: String) -> String { 216 + let parts = string.split(enum_value, "_") 217 + case parts { 218 + [first, ..rest] -> { 219 + let lower_first = string.lowercase(first) 220 + let capitalized_rest = list.map(rest, fn(part) { 221 + case string.pop_grapheme(string.lowercase(part)) { 222 + Ok(#(first_char, remaining)) -> string.uppercase(first_char) <> remaining 223 + Error(_) -> part 224 + } 225 + }) 226 + lower_first <> string.join(capitalized_rest, "") 227 + } 228 + [] -> enum_value 229 + } 230 + } 231 + 232 + /// Convert a ref to a camelCase field name for variant fields 233 + /// "com.atproto.label.defs#selfLabels" -> "selfLabels" 234 + pub fn ref_to_variant_field_name(ref: String) -> String { 235 + case string.split(ref, "#") { 236 + [_, name] -> name 237 + _ -> { 238 + case string.split(ref, ".") |> list.last { 239 + Ok(name) -> name 240 + Error(_) -> ref 241 + } 242 + } 243 + } 244 + } 245 + 246 + /// Build a GraphQL input type from an ObjectDef 247 + fn build_input_type_from_object_def( 248 + ref: String, 249 + obj_def: types.ObjectDef, 250 + existing_input_types: UnionInputRegistry, 251 + ) -> schema.Type { 252 + let type_name = ref_to_input_type_name(ref) 253 + let required_fields = obj_def.required_fields 254 + 255 + let input_fields = 256 + list.map(obj_def.properties, fn(prop) { 257 + let #(name, property) = prop 258 + let is_required = list.contains(required_fields, name) 259 + 260 + // Get the input type for this property 261 + let field_type = map_property_to_input_type(property, existing_input_types) 262 + 263 + // Wrap in non_null if required 264 + let final_type = case is_required { 265 + True -> schema.non_null(field_type) 266 + False -> field_type 267 + } 268 + 269 + schema.input_field(name, final_type, "Input for " <> name, option.None) 270 + }) 271 + 272 + schema.input_object_type(type_name, "Input type for " <> ref, input_fields) 273 + } 274 + 275 + /// Build a discriminated union input type for multi-variant unions 276 + /// Creates an input with a `type` enum and optional fields for each variant 277 + pub fn build_multi_variant_union_input( 278 + parent_type_name: String, 279 + field_name: String, 280 + refs: List(String), 281 + existing_input_types: UnionInputRegistry, 282 + ) -> schema.Type { 283 + let union_name = parent_type_name <> capitalize_first(field_name) <> "Input" 284 + let enum_name = parent_type_name <> capitalize_first(field_name) <> "Type" 285 + 286 + // Build the type enum with variant names 287 + let enum_values = 288 + list.map(refs, fn(ref) { 289 + let value_name = ref_to_variant_enum_value(ref) 290 + schema.enum_value(value_name, "Select " <> ref <> " variant") 291 + }) 292 + 293 + let type_enum = schema.enum_type( 294 + enum_name, 295 + "Discriminator for " <> field_name <> " union variants", 296 + enum_values, 297 + ) 298 + 299 + // Build input fields: required type discriminator + optional variant fields 300 + let type_field = schema.input_field( 301 + "type", 302 + schema.non_null(type_enum), 303 + "Select which variant to use", 304 + option.None, 305 + ) 306 + 307 + let variant_fields = 308 + list.map(refs, fn(ref) { 309 + let variant_field_name = ref_to_variant_field_name(ref) 310 + let field_type = case dict.get(existing_input_types, ref) { 311 + Ok(input_type) -> input_type 312 + Error(_) -> schema.string_type() 313 + } 314 + schema.input_field( 315 + variant_field_name, 316 + field_type, // Optional - user provides data for selected variant 317 + "Data for " <> ref <> " variant", 318 + option.None, 319 + ) 320 + }) 321 + 322 + let all_fields = [type_field, ..variant_fields] 323 + 324 + schema.input_object_type( 325 + union_name, 326 + "Union input for " <> field_name <> " - use type to select variant", 327 + all_fields, 328 + ) 329 + } 330 + 331 + /// Map a lexicon property to a GraphQL input type 332 + fn map_property_to_input_type( 333 + property: types.Property, 334 + existing_input_types: UnionInputRegistry, 335 + ) -> schema.Type { 336 + case property.type_ { 337 + // For refs, check if we have a generated input type 338 + "ref" -> { 339 + case property.ref { 340 + option.Some(ref) -> { 341 + case dict.get(existing_input_types, ref) { 342 + Ok(input_type) -> input_type 343 + // Fall back to basic type mapping if no input type exists 344 + Error(_) -> type_mapper.map_input_type("ref") 345 + } 346 + } 347 + option.None -> type_mapper.map_input_type("ref") 348 + } 349 + } 350 + 351 + // For arrays, handle items 352 + "array" -> { 353 + case property.items { 354 + option.Some(types.ArrayItems(item_type, item_ref, _item_refs)) -> { 355 + let item_input_type = case item_type { 356 + "ref" -> { 357 + case item_ref { 358 + option.Some(ref) -> { 359 + case dict.get(existing_input_types, ref) { 360 + Ok(input_type) -> input_type 361 + Error(_) -> type_mapper.map_input_type("ref") 362 + } 363 + } 364 + option.None -> type_mapper.map_input_type("ref") 365 + } 366 + } 367 + _ -> type_mapper.map_input_type(item_type) 368 + } 369 + schema.list_type(schema.non_null(item_input_type)) 370 + } 371 + option.None -> schema.list_type(schema.non_null(schema.string_type())) 372 + } 373 + } 374 + 375 + // For unions - handled differently based on variant count 376 + // Single-variant: use variant type directly 377 + // Multi-variant: caller should use build_multi_variant_union_input 378 + "union" -> { 379 + case property.refs { 380 + option.Some([single_ref]) -> { 381 + // Single-variant union: use that variant's input type directly 382 + case dict.get(existing_input_types, single_ref) { 383 + Ok(input_type) -> input_type 384 + Error(_) -> type_mapper.map_input_type("union") 385 + } 386 + } 387 + option.Some(_multiple_refs) -> { 388 + // Multi-variant: return placeholder, caller handles this 389 + type_mapper.map_input_type("union") 390 + } 391 + _ -> type_mapper.map_input_type("union") 392 + } 393 + } 394 + 395 + // Default: use type_mapper 396 + other -> type_mapper.map_input_type(other) 397 + } 398 + } 399 + 400 + /// Capitalize the first letter of a string 401 + fn capitalize_first(s: String) -> String { 402 + case string.pop_grapheme(s) { 403 + Ok(#(first, rest)) -> string.uppercase(first) <> rest 404 + Error(_) -> s 405 + } 406 + } 407 + 408 + /// Get an input type from the registry by ref 409 + pub fn get_input_type( 410 + registry: UnionRegistry, 411 + ref: String, 412 + ) -> Option(schema.Type) { 413 + dict.get(registry.input_types, ref) |> option.from_result 414 + } 415 + 416 + /// Check if a union has multiple variants 417 + pub fn is_multi_variant_union(refs: Option(List(String))) -> Bool { 418 + case refs { 419 + option.Some([_, _, ..]) -> True 420 + _ -> False 421 + } 422 + } 423 + 424 + /// Find the ref that matches an enum value 425 + pub fn enum_value_to_ref(enum_value: String, refs: List(String)) -> Option(String) { 426 + list.find(refs, fn(ref) { 427 + ref_to_variant_enum_value(ref) == enum_value 428 + }) 429 + |> option.from_result 430 + } 431 + ``` 432 + 433 + **Step 2: Verify compilation** 434 + 435 + Run: `cd lexicon_graphql && gleam build` 436 + Expected: Build succeeds (may have warnings about unused functions) 437 + 438 + **Step 3: Commit** 439 + 440 + ```bash 441 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/union_input_builder.gleam 442 + git commit -m "feat: add union input type builder with multi-variant support and field registry" 443 + ``` 444 + 445 + --- 446 + 447 + ### Task 2: Add get_all_object_defs to Registry 448 + 449 + **Files:** 450 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/lexicon/registry.gleam` 451 + 452 + **Step 1: Add function to get all object defs** 453 + 454 + Add this function after `get_object_def`: 455 + 456 + ```gleam 457 + /// Get all object definitions from the registry 458 + pub fn get_all_object_defs(registry: Registry) -> Dict(String, types.ObjectDef) { 459 + registry.object_defs 460 + } 461 + ``` 462 + 463 + **Step 2: Verify compilation** 464 + 465 + Run: `cd lexicon_graphql && gleam build` 466 + Expected: Build succeeds 467 + 468 + **Step 3: Commit** 469 + 470 + ```bash 471 + git add lexicon_graphql/src/lexicon_graphql/internal/lexicon/registry.gleam 472 + git commit -m "feat: add get_all_object_defs to lexicon registry" 473 + ``` 474 + 475 + --- 476 + 477 + ### Task 3: Update Type Mapper for Union Input Types 478 + 479 + **Files:** 480 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam` 481 + 482 + **Step 1: Add imports** 483 + 484 + Add after the existing imports: 485 + 486 + ```gleam 487 + import gleam/dict.{type Dict} 488 + import lexicon_graphql/types 489 + ``` 490 + 491 + **Step 2: Add new function for union input type resolution** 492 + 493 + Add this new function after `map_input_type`: 494 + 495 + ```gleam 496 + /// Context needed for building union input types 497 + pub type UnionInputContext { 498 + UnionInputContext( 499 + input_types: Dict(String, schema.Type), 500 + parent_type_name: String, 501 + ) 502 + } 503 + 504 + /// Maps a lexicon property to a GraphQL input type, with union input registry lookup 505 + /// This version can resolve union refs to their generated input types 506 + /// For multi-variant unions, builds discriminated union input types on demand 507 + pub fn map_input_type_with_unions( 508 + property: types.Property, 509 + field_name: String, 510 + ctx: UnionInputContext, 511 + ) -> schema.Type { 512 + case property.type_ { 513 + // For unions, handle single vs multi-variant 514 + "union" -> { 515 + case property.refs { 516 + // Single-variant: use variant's input type directly 517 + option.Some([single_ref]) -> { 518 + case dict.get(ctx.input_types, single_ref) { 519 + Ok(input_type) -> input_type 520 + Error(_) -> schema.string_type() 521 + } 522 + } 523 + // Multi-variant: build discriminated union input 524 + option.Some(refs) if list.length(refs) > 1 -> { 525 + build_multi_variant_union_input( 526 + ctx.parent_type_name, 527 + field_name, 528 + refs, 529 + ctx.input_types, 530 + ) 531 + } 532 + _ -> schema.string_type() 533 + } 534 + } 535 + 536 + // For refs, check the registry 537 + "ref" -> { 538 + case property.ref { 539 + option.Some(ref) -> { 540 + case dict.get(ctx.input_types, ref) { 541 + Ok(input_type) -> input_type 542 + Error(_) -> map_input_type("ref") 543 + } 544 + } 545 + option.None -> map_input_type("ref") 546 + } 547 + } 548 + 549 + // For arrays with ref/union items 550 + "array" -> { 551 + case property.items { 552 + option.Some(types.ArrayItems(item_type, item_ref, item_refs)) -> { 553 + case item_type { 554 + "ref" -> { 555 + case item_ref { 556 + option.Some(ref) -> { 557 + case dict.get(ctx.input_types, ref) { 558 + Ok(input_type) -> 559 + schema.list_type(schema.non_null(input_type)) 560 + Error(_) -> 561 + schema.list_type(schema.non_null(schema.string_type())) 562 + } 563 + } 564 + option.None -> 565 + schema.list_type(schema.non_null(schema.string_type())) 566 + } 567 + } 568 + "union" -> { 569 + case item_refs { 570 + // Single-variant array items 571 + option.Some([single_ref]) -> { 572 + case dict.get(ctx.input_types, single_ref) { 573 + Ok(input_type) -> 574 + schema.list_type(schema.non_null(input_type)) 575 + Error(_) -> 576 + schema.list_type(schema.non_null(schema.string_type())) 577 + } 578 + } 579 + // Multi-variant array items 580 + option.Some(refs) if list.length(refs) > 1 -> { 581 + let item_union = build_multi_variant_union_input( 582 + ctx.parent_type_name, 583 + field_name <> "Item", 584 + refs, 585 + ctx.input_types, 586 + ) 587 + schema.list_type(schema.non_null(item_union)) 588 + } 589 + _ -> schema.list_type(schema.non_null(schema.string_type())) 590 + } 591 + } 592 + _ -> map_array_type(property.items, dict.new(), "", "") 593 + } 594 + } 595 + option.None -> schema.list_type(schema.non_null(schema.string_type())) 596 + } 597 + } 598 + 599 + // Default to regular input type mapping 600 + _ -> map_input_type(property.type_) 601 + } 602 + } 603 + 604 + /// Build a discriminated union input type for multi-variant unions 605 + fn build_multi_variant_union_input( 606 + parent_type_name: String, 607 + field_name: String, 608 + refs: List(String), 609 + existing_input_types: Dict(String, schema.Type), 610 + ) -> schema.Type { 611 + let union_name = parent_type_name <> capitalize_first(field_name) <> "Input" 612 + let enum_name = parent_type_name <> capitalize_first(field_name) <> "Type" 613 + 614 + // Build the type enum with variant names 615 + let enum_values = 616 + list.map(refs, fn(ref) { 617 + let value_name = ref_to_variant_enum_value(ref) 618 + schema.enum_value(value_name, "Select " <> ref <> " variant") 619 + }) 620 + 621 + let type_enum = schema.enum_type( 622 + enum_name, 623 + "Discriminator for " <> field_name <> " union variants", 624 + enum_values, 625 + ) 626 + 627 + // Build input fields: required type discriminator + optional variant fields 628 + let type_field = schema.input_field( 629 + "type", 630 + schema.non_null(type_enum), 631 + "Select which variant to use", 632 + option.None, 633 + ) 634 + 635 + let variant_fields = 636 + list.map(refs, fn(ref) { 637 + let variant_field_name = ref_to_variant_field_name(ref) 638 + let field_type = case dict.get(existing_input_types, ref) { 639 + Ok(input_type) -> input_type 640 + Error(_) -> schema.string_type() 641 + } 642 + schema.input_field( 643 + variant_field_name, 644 + field_type, // Optional - user provides data for selected variant 645 + "Data for " <> ref <> " variant", 646 + option.None, 647 + ) 648 + }) 649 + 650 + let all_fields = [type_field, ..variant_fields] 651 + 652 + schema.input_object_type( 653 + union_name, 654 + "Union input for " <> field_name <> " - use type field to select variant", 655 + all_fields, 656 + ) 657 + } 658 + 659 + /// Convert a ref to SCREAMING_SNAKE_CASE enum value 660 + fn ref_to_variant_enum_value(ref: String) -> String { 661 + let short_name = case string.split(ref, "#") { 662 + [_, name] -> name 663 + _ -> { 664 + case string.split(ref, ".") |> list.last { 665 + Ok(name) -> name 666 + Error(_) -> ref 667 + } 668 + } 669 + } 670 + camel_to_screaming_snake(short_name) 671 + } 672 + 673 + /// Convert camelCase to SCREAMING_SNAKE_CASE 674 + fn camel_to_screaming_snake(s: String) -> String { 675 + s 676 + |> string.to_graphemes 677 + |> list.fold(#("", False), fn(acc, char) { 678 + let #(result, prev_was_lower) = acc 679 + let is_upper = string.uppercase(char) == char && string.lowercase(char) != char 680 + case is_upper, prev_was_lower { 681 + True, True -> #(result <> "_" <> char, False) 682 + _, _ -> #(result <> string.uppercase(char), !is_upper) 683 + } 684 + }) 685 + |> fn(pair) { pair.0 } 686 + } 687 + 688 + /// Convert a ref to a camelCase field name 689 + fn ref_to_variant_field_name(ref: String) -> String { 690 + case string.split(ref, "#") { 691 + [_, name] -> name 692 + _ -> { 693 + case string.split(ref, ".") |> list.last { 694 + Ok(name) -> name 695 + Error(_) -> ref 696 + } 697 + } 698 + } 699 + } 700 + 701 + /// Capitalize the first letter 702 + fn capitalize_first(s: String) -> String { 703 + case string.pop_grapheme(s) { 704 + Ok(#(first, rest)) -> string.uppercase(first) <> rest 705 + Error(_) -> s 706 + } 707 + } 708 + ``` 709 + 710 + **Step 3: Verify compilation** 711 + 712 + Run: `cd lexicon_graphql && gleam build` 713 + Expected: Build succeeds 714 + 715 + **Step 4: Commit** 716 + 717 + ```bash 718 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam 719 + git commit -m "feat: add map_input_type_with_unions with multi-variant support" 720 + ``` 721 + 722 + --- 723 + 724 + ### Task 4: Update Mutation Builder to Build and Return Union Registry 725 + 726 + **Files:** 727 + - Modify: `lexicon_graphql/src/lexicon_graphql/mutation/builder.gleam` 728 + 729 + **Step 1: Add imports** 730 + 731 + Add to imports: 732 + 733 + ```gleam 734 + import lexicon_graphql/internal/graphql/union_input_builder 735 + import lexicon_graphql/internal/lexicon/registry as lexicon_registry 736 + ``` 737 + 738 + **Step 2: Create new return type that includes the union registry** 739 + 740 + Add after the existing type definitions: 741 + 742 + ```gleam 743 + /// Result of building mutation type - includes the type and union field registry 744 + pub type MutationBuildResult { 745 + MutationBuildResult( 746 + mutation_type: schema.Type, 747 + union_registry: union_input_builder.UnionRegistry, 748 + ) 749 + } 750 + ``` 751 + 752 + **Step 3: Update build_mutation_type to return MutationBuildResult** 753 + 754 + ```gleam 755 + /// Build a GraphQL Mutation type from lexicon definitions 756 + /// Returns both the mutation type and a union registry for transformation 757 + pub fn build_mutation_type( 758 + lexicons: List(types.Lexicon), 759 + object_types: dict.Dict(String, schema.Type), 760 + create_factory: option.Option(ResolverFactory), 761 + update_factory: option.Option(ResolverFactory), 762 + delete_factory: option.Option(ResolverFactory), 763 + upload_blob_factory: option.Option(UploadBlobResolverFactory), 764 + custom_fields: option.Option(List(schema.Field)), 765 + registry: option.Option(lexicon_registry.Registry), 766 + ) -> MutationBuildResult { 767 + // Build union input types if registry is provided 768 + let initial_union_registry = case registry { 769 + option.Some(reg) -> union_input_builder.build_union_input_types(reg) 770 + option.None -> union_input_builder.UnionRegistry( 771 + input_types: dict.new(), 772 + field_variants: dict.new(), 773 + ) 774 + } 775 + 776 + // Extract record types 777 + let record_types = extract_record_types(lexicons) 778 + 779 + // Build mutation fields for each record type, accumulating union field registrations 780 + let #(record_mutation_fields, final_union_registry) = 781 + list.fold(record_types, #([], initial_union_registry), fn(acc, record) { 782 + let #(fields_acc, registry_acc) = acc 783 + let #(new_fields, updated_registry) = build_mutations_for_record( 784 + record, 785 + object_types, 786 + create_factory, 787 + update_factory, 788 + delete_factory, 789 + registry_acc, 790 + ) 791 + #(list.append(fields_acc, new_fields), updated_registry) 792 + }) 793 + 794 + // Add uploadBlob mutation if factory is provided 795 + let with_upload_blob = case upload_blob_factory { 796 + option.Some(factory) -> { 797 + let upload_blob_mutation = build_upload_blob_mutation(factory) 798 + [upload_blob_mutation, ..record_mutation_fields] 799 + } 800 + option.None -> record_mutation_fields 801 + } 802 + 803 + // Add custom fields if provided 804 + let all_mutation_fields = case custom_fields { 805 + option.Some(fields) -> list.append(with_upload_blob, fields) 806 + option.None -> with_upload_blob 807 + } 808 + 809 + // Build the Mutation object type 810 + let mutation_type = schema.object_type("Mutation", "Root mutation type", all_mutation_fields) 811 + 812 + MutationBuildResult( 813 + mutation_type: mutation_type, 814 + union_registry: final_union_registry, 815 + ) 816 + } 817 + ``` 818 + 819 + **Step 4: Update build_mutations_for_record to return updated registry** 820 + 821 + ```gleam 822 + fn build_mutations_for_record( 823 + record: RecordInfo, 824 + object_types: dict.Dict(String, schema.Type), 825 + create_factory: option.Option(ResolverFactory), 826 + update_factory: option.Option(ResolverFactory), 827 + delete_factory: option.Option(ResolverFactory), 828 + union_registry: union_input_builder.UnionRegistry, 829 + ) -> #(List(schema.Field), union_input_builder.UnionRegistry) { 830 + // Build input type and get updated registry with union field mappings 831 + let #(input_type, updated_registry) = build_input_type_with_registry( 832 + record.type_name <> "Input", 833 + record.nsid, 834 + record.properties, 835 + union_registry, 836 + ) 837 + 838 + let create_mutation = 839 + build_create_mutation(record, object_types, create_factory, input_type) 840 + let update_mutation = 841 + build_update_mutation(record, object_types, update_factory, input_type) 842 + let delete_mutation = build_delete_mutation(record, delete_factory) 843 + 844 + #([create_mutation, update_mutation, delete_mutation], updated_registry) 845 + } 846 + ``` 847 + 848 + **Step 5: Update build_input_type to register union fields** 849 + 850 + ```gleam 851 + /// Build an InputObjectType and register union fields 852 + fn build_input_type_with_registry( 853 + type_name: String, 854 + collection: String, 855 + properties: List(#(String, types.Property)), 856 + union_registry: union_input_builder.UnionRegistry, 857 + ) -> #(schema.Type, union_input_builder.UnionRegistry) { 858 + // Create context for union input type resolution 859 + let ctx = type_mapper.UnionInputContext( 860 + input_types: union_registry.input_types, 861 + parent_type_name: type_name, 862 + ) 863 + 864 + // Build fields and register union fields 865 + let #(input_fields, final_registry) = 866 + list.fold(properties, #([], union_registry), fn(acc, prop) { 867 + let #(fields_acc, registry_acc) = acc 868 + let #(name, types.Property(type_, required, _, ref, refs, items)) = prop 869 + 870 + // Build property for type mapping 871 + let property = types.Property(type_, required, option.None, ref, refs, items) 872 + 873 + // Use union-aware type mapping with field name for multi-variant naming 874 + let graphql_type = type_mapper.map_input_type_with_unions(property, name, ctx) 875 + 876 + // Register union fields for later transformation 877 + let updated_registry = case type_, refs { 878 + "union", option.Some(ref_list) -> { 879 + union_input_builder.register_union_field(registry_acc, collection, name, ref_list) 880 + } 881 + _, _ -> registry_acc 882 + } 883 + 884 + // Make required fields non-null 885 + let field_type = case required { 886 + True -> schema.non_null(graphql_type) 887 + False -> graphql_type 888 + } 889 + 890 + let input_field = schema.input_field( 891 + name, 892 + field_type, 893 + "Input field for " <> name, 894 + option.None, 895 + ) 896 + 897 + #([input_field, ..fields_acc], updated_registry) 898 + }) 899 + 900 + let reversed_fields = list.reverse(input_fields) 901 + let input_type = schema.input_object_type( 902 + type_name, 903 + "Input type for " <> type_name, 904 + reversed_fields, 905 + ) 906 + 907 + #(input_type, final_registry) 908 + } 909 + ``` 910 + 911 + **Step 6: Update create and update mutation builders to accept input_type directly** 912 + 913 + ```gleam 914 + fn build_create_mutation( 915 + record: RecordInfo, 916 + object_types: dict.Dict(String, schema.Type), 917 + factory: option.Option(ResolverFactory), 918 + input_type: schema.Type, 919 + ) -> schema.Field { 920 + let mutation_name = "create" <> record.type_name 921 + 922 + // Get the complete object type from the dict (includes all join fields) 923 + let assert Ok(return_type) = dict.get(object_types, record.nsid) 924 + 925 + // Create arguments 926 + let arguments = [ 927 + schema.argument( 928 + "input", 929 + schema.non_null(input_type), 930 + "Record data", 931 + option.None, 932 + ), 933 + schema.argument( 934 + "rkey", 935 + schema.string_type(), 936 + "Optional record key (defaults to TID)", 937 + option.None, 938 + ), 939 + ] 940 + 941 + // Get resolver - either from factory or use stub 942 + let collection = record.nsid 943 + let resolver = case factory { 944 + option.Some(factory_fn) -> factory_fn(collection) 945 + option.None -> fn(_resolver_ctx) { 946 + Error("Create mutation for " <> collection <> " not yet implemented.") 947 + } 948 + } 949 + 950 + schema.field_with_args( 951 + mutation_name, 952 + return_type, 953 + "Create a new " <> record.nsid <> " record", 954 + arguments, 955 + resolver, 956 + ) 957 + } 958 + ``` 959 + 960 + (Same pattern for `build_update_mutation`) 961 + 962 + **Step 7: Verify compilation** 963 + 964 + Run: `cd lexicon_graphql && gleam build` 965 + Expected: Build succeeds 966 + 967 + **Step 8: Commit** 968 + 969 + ```bash 970 + git add lexicon_graphql/src/lexicon_graphql/mutation/builder.gleam 971 + git commit -m "feat: mutation builder returns union registry for dynamic transformation" 972 + ``` 973 + 974 + --- 975 + 976 + ### Task 5: Update Server Schema to Use MutationBuildResult 977 + 978 + **Files:** 979 + - Modify: `server/src/graphql/lexicon/schema.gleam` 980 + 981 + **Step 1: Update to use MutationBuildResult** 982 + 983 + Where mutation type is built, extract the union registry: 984 + 985 + ```gleam 986 + // Build mutation type and get union registry 987 + let mutation_build_result = mutation_builder.build_mutation_type( 988 + lexicons, 989 + object_types, 990 + create_factory, 991 + update_factory, 992 + delete_factory, 993 + upload_blob_factory, 994 + custom_mutation_fields, 995 + option.Some(registry), 996 + ) 997 + 998 + let mutation_type = mutation_build_result.mutation_type 999 + let union_registry = mutation_build_result.union_registry 1000 + ``` 1001 + 1002 + **Step 2: Pass union_registry to mutation context** 1003 + 1004 + The union_registry needs to be available to mutation resolvers. Update the schema building to include it in the resolver factories or mutation context. 1005 + 1006 + **Step 3: Verify compilation** 1007 + 1008 + Run: `cd server && gleam build` 1009 + Expected: Build succeeds 1010 + 1011 + **Step 4: Commit** 1012 + 1013 + ```bash 1014 + git add server/src/graphql/lexicon/schema.gleam 1015 + git commit -m "feat: extract union registry from mutation builder" 1016 + ``` 1017 + 1018 + --- 1019 + 1020 + ### Task 6: Add Union Input Transformation in Mutations 1021 + 1022 + **Files:** 1023 + - Modify: `server/src/graphql/lexicon/mutations.gleam` 1024 + 1025 + **Step 1: Add union registry to MutationContext** 1026 + 1027 + Update MutationContext to include the union registry: 1028 + 1029 + ```gleam 1030 + import lexicon_graphql/internal/graphql/union_input_builder 1031 + 1032 + pub type MutationContext { 1033 + MutationContext( 1034 + db: Executor, 1035 + did_cache: Subject(did_cache.Message), 1036 + signing_key: option.Option(String), 1037 + atp_client_id: String, 1038 + plc_url: String, 1039 + collection_ids: List(String), 1040 + external_collection_ids: List(String), 1041 + union_registry: union_input_builder.UnionRegistry, // Add this 1042 + ) 1043 + } 1044 + ``` 1045 + 1046 + **Step 2: Add dynamic union transformation helpers** 1047 + 1048 + ```gleam 1049 + /// Transform union input using registry-based lookup 1050 + /// For single-variant: adds $type based on the single ref 1051 + /// For multi-variant: reads "type" enum field, maps to ref, extracts variant data 1052 + fn transform_union_input( 1053 + collection: String, 1054 + field_name: String, 1055 + fields: List(#(String, value.Value)), 1056 + union_registry: union_input_builder.UnionRegistry, 1057 + ) -> value.Value { 1058 + // Look up the refs for this field 1059 + case union_input_builder.get_union_refs(union_registry, collection, field_name) { 1060 + option.Some([single_ref]) -> { 1061 + // Single-variant: just add $type 1062 + let with_type = [#("$type", value.String(single_ref)), ..fields] 1063 + value.Object(with_type) 1064 + } 1065 + option.Some(refs) -> { 1066 + // Multi-variant: read "type" field and extract variant data 1067 + transform_multi_variant_union(fields, refs) 1068 + } 1069 + option.None -> { 1070 + // Not a registered union field, pass through 1071 + value.Object(fields) 1072 + } 1073 + } 1074 + } 1075 + 1076 + /// Transform multi-variant union input 1077 + /// Input: { type: "SELF_LABELS", selfLabels: { values: [...] } } 1078 + /// Output: { "$type": "com.atproto.label.defs#selfLabels", "values": [...] } 1079 + fn transform_multi_variant_union( 1080 + fields: List(#(String, value.Value)), 1081 + refs: List(String), 1082 + ) -> value.Value { 1083 + // Extract the type discriminator 1084 + let type_value = case list.key_find(fields, "type") { 1085 + Ok(value.String(v)) -> option.Some(v) 1086 + Ok(value.Enum(v)) -> option.Some(v) 1087 + _ -> option.None 1088 + } 1089 + 1090 + case type_value { 1091 + option.Some(enum_value) -> { 1092 + // Find which ref matches this enum value 1093 + case union_input_builder.enum_value_to_ref(enum_value, refs) { 1094 + option.Some(type_ref) -> { 1095 + // Get the variant field name 1096 + let variant_field_name = union_input_builder.ref_to_variant_field_name(type_ref) 1097 + 1098 + // Extract variant data 1099 + case list.key_find(fields, variant_field_name) { 1100 + Ok(value.Object(variant_fields)) -> { 1101 + // Build AT Protocol format 1102 + let with_type = [#("$type", value.String(type_ref)), ..variant_fields] 1103 + value.Object(with_type) 1104 + } 1105 + _ -> value.Object(fields) // No variant data 1106 + } 1107 + } 1108 + option.None -> value.Object(fields) // Unknown enum value 1109 + } 1110 + } 1111 + option.None -> value.Object(fields) // No type field 1112 + } 1113 + } 1114 + 1115 + /// Check if this field is a union field in the registry 1116 + fn is_registered_union_field( 1117 + collection: String, 1118 + field_name: String, 1119 + union_registry: union_input_builder.UnionRegistry, 1120 + ) -> Bool { 1121 + case union_input_builder.get_union_refs(union_registry, collection, field_name) { 1122 + option.Some(_) -> True 1123 + option.None -> False 1124 + } 1125 + } 1126 + ``` 1127 + 1128 + **Step 3: Update transform_record_value to use registry** 1129 + 1130 + ```gleam 1131 + fn transform_record_value( 1132 + val: value.Value, 1133 + field_name: String, 1134 + collection: String, 1135 + union_registry: union_input_builder.UnionRegistry, 1136 + ) -> value.Value { 1137 + case val { 1138 + value.Object(fields) -> { 1139 + // Check if this is a registered union field 1140 + case is_registered_union_field(collection, field_name, union_registry) { 1141 + True -> transform_union_input(collection, field_name, fields, union_registry) 1142 + False -> { 1143 + // Check for blob pattern 1144 + case is_blob_input(fields) { 1145 + True -> transform_blob_object(fields) 1146 + False -> { 1147 + // Recursively transform nested objects 1148 + value.Object( 1149 + list.map(fields, fn(pair) { 1150 + let #(name, v) = pair 1151 + #(name, transform_record_value(v, name, collection, union_registry)) 1152 + }), 1153 + ) 1154 + } 1155 + } 1156 + } 1157 + } 1158 + } 1159 + value.List(items) -> 1160 + value.List(list.map(items, fn(item) { 1161 + transform_record_value(item, field_name, collection, union_registry) 1162 + })) 1163 + _ -> val 1164 + } 1165 + } 1166 + 1167 + /// Check if an object looks like a blob input 1168 + fn is_blob_input(fields: List(#(String, value.Value))) -> Bool { 1169 + case list.key_find(fields, "ref"), list.key_find(fields, "mimeType") { 1170 + Ok(_), Ok(_) -> True 1171 + _, _ -> False 1172 + } 1173 + } 1174 + ``` 1175 + 1176 + **Step 4: Update mutation resolvers to pass union_registry** 1177 + 1178 + In the create/update resolver factories, pass the union_registry to transform_record_value: 1179 + 1180 + ```gleam 1181 + // In create_resolver_factory or similar: 1182 + let transformed_input = transform_record_value( 1183 + input_value, 1184 + "input", 1185 + collection, 1186 + ctx.union_registry, 1187 + ) 1188 + ``` 1189 + 1190 + **Step 5: Verify compilation** 1191 + 1192 + Run: `cd server && gleam build` 1193 + Expected: Build succeeds 1194 + 1195 + **Step 6: Commit** 1196 + 1197 + ```bash 1198 + git add server/src/graphql/lexicon/mutations.gleam 1199 + git commit -m "feat: dynamic union transformation using registry lookup" 1200 + ``` 1201 + 1202 + --- 1203 + 1204 + ### Task 7: Write Unit Tests 1205 + 1206 + **Files:** 1207 + - Create: `lexicon_graphql/test/union_input_builder_test.gleam` 1208 + 1209 + **Step 1: Create test file** 1210 + 1211 + ```gleam 1212 + import gleam/dict 1213 + import gleam/option 1214 + import gleeunit/should 1215 + import lexicon_graphql/internal/graphql/union_input_builder 1216 + import lexicon_graphql/internal/graphql/type_mapper 1217 + import lexicon_graphql/internal/lexicon/registry as lexicon_registry 1218 + import lexicon_graphql/types 1219 + import swell/schema 1220 + 1221 + pub fn ref_to_input_type_name_test() { 1222 + union_input_builder.ref_to_input_type_name("com.atproto.label.defs#selfLabels") 1223 + |> should.equal("ComAtprotoLabelDefsSelfLabelsInput") 1224 + } 1225 + 1226 + pub fn ref_to_variant_enum_value_test() { 1227 + union_input_builder.ref_to_variant_enum_value("com.atproto.label.defs#selfLabels") 1228 + |> should.equal("SELF_LABELS") 1229 + } 1230 + 1231 + pub fn ref_to_variant_enum_value_camel_case_test() { 1232 + union_input_builder.ref_to_variant_enum_value("app.bsky.embed.recordWithMedia") 1233 + |> should.equal("RECORD_WITH_MEDIA") 1234 + } 1235 + 1236 + pub fn ref_to_variant_field_name_test() { 1237 + union_input_builder.ref_to_variant_field_name("com.atproto.label.defs#selfLabels") 1238 + |> should.equal("selfLabels") 1239 + } 1240 + 1241 + pub fn enum_value_to_short_name_test() { 1242 + union_input_builder.enum_value_to_short_name("SELF_LABELS") 1243 + |> should.equal("selfLabels") 1244 + } 1245 + 1246 + pub fn enum_value_to_short_name_single_word_test() { 1247 + union_input_builder.enum_value_to_short_name("IMAGES") 1248 + |> should.equal("images") 1249 + } 1250 + 1251 + pub fn is_multi_variant_union_single_test() { 1252 + union_input_builder.is_multi_variant_union(option.Some(["ref1"])) 1253 + |> should.be_false() 1254 + } 1255 + 1256 + pub fn is_multi_variant_union_multiple_test() { 1257 + union_input_builder.is_multi_variant_union(option.Some(["ref1", "ref2"])) 1258 + |> should.be_true() 1259 + } 1260 + 1261 + pub fn register_and_get_union_field_test() { 1262 + let registry = union_input_builder.UnionRegistry( 1263 + input_types: dict.new(), 1264 + field_variants: dict.new(), 1265 + ) 1266 + 1267 + let refs = ["com.atproto.label.defs#selfLabels"] 1268 + let updated = union_input_builder.register_union_field( 1269 + registry, 1270 + "social.grain.gallery", 1271 + "labels", 1272 + refs, 1273 + ) 1274 + 1275 + case union_input_builder.get_union_refs(updated, "social.grain.gallery", "labels") { 1276 + option.Some(found_refs) -> found_refs |> should.equal(refs) 1277 + option.None -> should.fail() 1278 + } 1279 + } 1280 + 1281 + pub fn enum_value_to_ref_test() { 1282 + let refs = [ 1283 + "com.atproto.label.defs#selfLabels", 1284 + "app.bsky.embed.images", 1285 + ] 1286 + 1287 + case union_input_builder.enum_value_to_ref("SELF_LABELS", refs) { 1288 + option.Some(ref) -> ref |> should.equal("com.atproto.label.defs#selfLabels") 1289 + option.None -> should.fail() 1290 + } 1291 + 1292 + case union_input_builder.enum_value_to_ref("IMAGES", refs) { 1293 + option.Some(ref) -> ref |> should.equal("app.bsky.embed.images") 1294 + option.None -> should.fail() 1295 + } 1296 + } 1297 + 1298 + pub fn build_union_input_types_creates_input_types_test() { 1299 + let self_label_def = types.ObjectDef( 1300 + type_: "object", 1301 + required_fields: ["val"], 1302 + properties: [ 1303 + #("val", types.Property("string", True, option.None, option.None, option.None, option.None)), 1304 + ], 1305 + ) 1306 + 1307 + let self_labels_def = types.ObjectDef( 1308 + type_: "object", 1309 + required_fields: ["values"], 1310 + properties: [ 1311 + #("values", types.Property( 1312 + "array", 1313 + True, 1314 + option.None, 1315 + option.None, 1316 + option.None, 1317 + option.Some(types.ArrayItems("ref", option.Some("com.atproto.label.defs#selfLabel"), option.None)), 1318 + )), 1319 + ], 1320 + ) 1321 + 1322 + let object_defs = dict.from_list([ 1323 + #("com.atproto.label.defs#selfLabel", self_label_def), 1324 + #("com.atproto.label.defs#selfLabels", self_labels_def), 1325 + ]) 1326 + 1327 + let registry = lexicon_registry.Registry( 1328 + lexicons: dict.new(), 1329 + object_defs: object_defs, 1330 + ) 1331 + 1332 + let result = union_input_builder.build_union_input_types(registry) 1333 + 1334 + dict.size(result.input_types) |> should.equal(2) 1335 + 1336 + case dict.get(result.input_types, "com.atproto.label.defs#selfLabel") { 1337 + Ok(input_type) -> { 1338 + schema.type_name(input_type) 1339 + |> should.equal("ComAtprotoLabelDefsSelfLabelInput") 1340 + } 1341 + Error(_) -> should.fail() 1342 + } 1343 + } 1344 + 1345 + pub fn multi_variant_union_input_has_type_enum_test() { 1346 + let input_types = dict.from_list([ 1347 + #("app.bsky.embed.images", schema.input_object_type("ImagesInput", "Images", [])), 1348 + #("app.bsky.embed.external", schema.input_object_type("ExternalInput", "External", [])), 1349 + ]) 1350 + 1351 + let ctx = type_mapper.UnionInputContext( 1352 + input_types: input_types, 1353 + parent_type_name: "Post", 1354 + ) 1355 + 1356 + let property = types.Property( 1357 + "union", 1358 + False, 1359 + option.None, 1360 + option.None, 1361 + option.Some(["app.bsky.embed.images", "app.bsky.embed.external"]), 1362 + option.None, 1363 + ) 1364 + 1365 + let result = type_mapper.map_input_type_with_unions(property, "embed", ctx) 1366 + 1367 + schema.type_name(result) |> should.equal("PostEmbedInput") 1368 + schema.is_input_object(result) |> should.be_true() 1369 + } 1370 + ``` 1371 + 1372 + **Step 2: Run tests** 1373 + 1374 + Run: `cd lexicon_graphql && gleam test` 1375 + Expected: Tests pass 1376 + 1377 + **Step 3: Commit** 1378 + 1379 + ```bash 1380 + git add lexicon_graphql/test/union_input_builder_test.gleam 1381 + git commit -m "test: add unit tests for union input builder with registry" 1382 + ``` 1383 + 1384 + --- 1385 + 1386 + ### Task 8: Integration Test 1387 + 1388 + **Step 1: Start server and test** 1389 + 1390 + Follow manual testing steps from original plan. 1391 + 1392 + **Step 2: Commit fixes** 1393 + 1394 + --- 1395 + 1396 + ### Task 9: Final Verification 1397 + 1398 + **Step 1: Run all tests** 1399 + 1400 + **Step 2: Final commit** 1401 + 1402 + --- 1403 + 1404 + ## Usage Examples 1405 + 1406 + ### Single-Variant Union (selfLabels) 1407 + 1408 + ```graphql 1409 + mutation { 1410 + createSocialGrainGallery(input: { 1411 + title: "My Gallery" 1412 + images: [] 1413 + labels: { 1414 + values: [{ val: "art" }, { val: "photography" }] 1415 + } 1416 + }) { 1417 + uri 1418 + } 1419 + } 1420 + ``` 1421 + 1422 + ### Multi-Variant Union (embed) 1423 + 1424 + ```graphql 1425 + mutation { 1426 + createAppBskyFeedPost(input: { 1427 + text: "Check this out!" 1428 + embed: { 1429 + type: IMAGES # Discriminator selects variant 1430 + images: { # Provide data for selected variant 1431 + images: [{ alt: "Photo", image: { ref: "...", mimeType: "image/jpeg", size: 1234 } }] 1432 + } 1433 + } 1434 + }) { 1435 + uri 1436 + } 1437 + } 1438 + ``` 1439 + 1440 + --- 1441 + 1442 + ## Notes 1443 + 1444 + - **No hardcoded union variants** - everything is derived from lexicons 1445 + - Union field registry is built during schema generation from lexicon definitions 1446 + - Registry maps `"collection.fieldName"` -> `[refs]` for dynamic lookup 1447 + - Single-variant unions use the variant type directly 1448 + - Multi-variant unions get a `type` enum field + optional variant fields 1449 + - Server transformation uses registry lookup, not hardcoded patterns
+1432
dev-docs/plans/2025-12-29-labels-and-reports.md
··· 1 + # Labels and Reports Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add AT Protocol-compatible labels and moderation reports to quickslice for self-moderation. 6 + 7 + **Architecture:** Three new tables (label_definition, label, report) with admin API for management and lexicon API for user reports. Labels attach to records/accounts via URI, with server-side filtering for `!takedown`/`!suspend` labels. Labels field added to all record types for client-side display decisions. 8 + 9 + **Tech Stack:** Gleam, SQLite/PostgreSQL, GraphQL (swell library), existing repository patterns. 10 + 11 + --- 12 + 13 + ## Task 1: Database Migration 14 + 15 + **Files:** 16 + - Create: `server/db/migrations/20251229000001_add_labels_and_reports.sql` 17 + 18 + **Step 1: Write the migration SQL** 19 + 20 + ```sql 21 + -- migrate:up 22 + 23 + -- ============================================================================= 24 + -- Label Definition Table 25 + -- ============================================================================= 26 + 27 + -- Defines available label values for this instance 28 + CREATE TABLE IF NOT EXISTS label_definition ( 29 + val TEXT PRIMARY KEY NOT NULL, 30 + description TEXT NOT NULL, 31 + severity TEXT NOT NULL CHECK (severity IN ('inform', 'alert', 'takedown')), 32 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 33 + ); 34 + 35 + -- Seed default label definitions (Bluesky-compatible) 36 + INSERT INTO label_definition (val, description, severity) VALUES 37 + ('!takedown', 'Content removed by moderators', 'takedown'), 38 + ('!suspend', 'Account suspended', 'takedown'), 39 + ('!warn', 'Show warning before displaying', 'alert'), 40 + ('!hide', 'Hide from feeds (still accessible via direct link)', 'alert'), 41 + ('porn', 'Pornographic content', 'alert'), 42 + ('sexual', 'Sexually suggestive content', 'alert'), 43 + ('nudity', 'Non-sexual nudity', 'alert'), 44 + ('gore', 'Graphic violence or gore', 'alert'), 45 + ('graphic-media', 'Disturbing or graphic media', 'alert'), 46 + ('impersonation', 'Account impersonating someone', 'inform'), 47 + ('spam', 'Spam or unwanted content', 'inform'); 48 + 49 + -- ============================================================================= 50 + -- Label Table 51 + -- ============================================================================= 52 + 53 + -- Applied labels on records/accounts 54 + CREATE TABLE IF NOT EXISTS label ( 55 + id INTEGER PRIMARY KEY AUTOINCREMENT, 56 + src TEXT NOT NULL, 57 + uri TEXT NOT NULL, 58 + cid TEXT, 59 + val TEXT NOT NULL, 60 + neg INTEGER NOT NULL DEFAULT 0, 61 + cts TEXT NOT NULL DEFAULT (datetime('now')), 62 + exp TEXT, 63 + FOREIGN KEY (val) REFERENCES label_definition(val) 64 + ); 65 + 66 + CREATE INDEX IF NOT EXISTS idx_label_uri ON label(uri); 67 + CREATE INDEX IF NOT EXISTS idx_label_val ON label(val); 68 + CREATE INDEX IF NOT EXISTS idx_label_src ON label(src); 69 + CREATE INDEX IF NOT EXISTS idx_label_cts ON label(cts DESC); 70 + 71 + -- ============================================================================= 72 + -- Report Table 73 + -- ============================================================================= 74 + 75 + -- User-submitted reports awaiting review 76 + CREATE TABLE IF NOT EXISTS report ( 77 + id INTEGER PRIMARY KEY AUTOINCREMENT, 78 + reporter_did TEXT NOT NULL, 79 + subject_uri TEXT NOT NULL, 80 + reason_type TEXT NOT NULL CHECK (reason_type IN ('spam', 'violation', 'misleading', 'sexual', 'rude', 'other')), 81 + reason TEXT, 82 + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'resolved', 'dismissed')), 83 + resolved_by TEXT, 84 + resolved_at TEXT, 85 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 86 + ); 87 + 88 + CREATE INDEX IF NOT EXISTS idx_report_status ON report(status); 89 + CREATE INDEX IF NOT EXISTS idx_report_subject_uri ON report(subject_uri); 90 + CREATE INDEX IF NOT EXISTS idx_report_reporter_did ON report(reporter_did); 91 + CREATE INDEX IF NOT EXISTS idx_report_created_at ON report(created_at DESC); 92 + 93 + -- migrate:down 94 + 95 + DROP TABLE IF EXISTS report; 96 + DROP TABLE IF EXISTS label; 97 + DROP TABLE IF EXISTS label_definition; 98 + ``` 99 + 100 + **Step 2: Verify migration file exists** 101 + 102 + Run: `ls -la server/db/migrations/20251229000001_add_labels_and_reports.sql` 103 + Expected: File exists with correct permissions 104 + 105 + **Step 3: Commit** 106 + 107 + ```bash 108 + git add server/db/migrations/20251229000001_add_labels_and_reports.sql 109 + git commit -m "feat(db): add labels and reports tables migration" 110 + ``` 111 + 112 + --- 113 + 114 + ## Task 2: Label Definition Repository 115 + 116 + **Files:** 117 + - Create: `server/src/database/repositories/label_definitions.gleam` 118 + 119 + **Step 1: Write the repository module** 120 + 121 + ```gleam 122 + /// Repository for label definitions 123 + import database/executor.{type DbError, type Executor, Text} 124 + import gleam/dynamic/decode 125 + import gleam/option.{type Option, None, Some} 126 + import gleam/result 127 + 128 + /// Label definition domain type 129 + pub type LabelDefinition { 130 + LabelDefinition( 131 + val: String, 132 + description: String, 133 + severity: String, 134 + created_at: String, 135 + ) 136 + } 137 + 138 + /// Get all label definitions 139 + pub fn get_all(exec: Executor) -> Result(List(LabelDefinition), DbError) { 140 + let sql = "SELECT val, description, severity, created_at FROM label_definition ORDER BY val" 141 + executor.query(exec, sql, [], label_definition_decoder()) 142 + } 143 + 144 + /// Get a label definition by value 145 + pub fn get(exec: Executor, val: String) -> Result(Option(LabelDefinition), DbError) { 146 + let sql = "SELECT val, description, severity, created_at FROM label_definition WHERE val = " <> executor.placeholder(exec, 1) 147 + case executor.query(exec, sql, [Text(val)], label_definition_decoder()) { 148 + Ok([def]) -> Ok(Some(def)) 149 + Ok(_) -> Ok(None) 150 + Error(e) -> Error(e) 151 + } 152 + } 153 + 154 + /// Insert a new label definition 155 + pub fn insert( 156 + exec: Executor, 157 + val: String, 158 + description: String, 159 + severity: String, 160 + ) -> Result(Nil, DbError) { 161 + let p1 = executor.placeholder(exec, 1) 162 + let p2 = executor.placeholder(exec, 2) 163 + let p3 = executor.placeholder(exec, 3) 164 + let sql = "INSERT INTO label_definition (val, description, severity) VALUES (" <> p1 <> ", " <> p2 <> ", " <> p3 <> ")" 165 + executor.exec(exec, sql, [Text(val), Text(description), Text(severity)]) 166 + } 167 + 168 + /// Check if a label value exists 169 + pub fn exists(exec: Executor, val: String) -> Result(Bool, DbError) { 170 + case get(exec, val) { 171 + Ok(Some(_)) -> Ok(True) 172 + Ok(None) -> Ok(False) 173 + Error(e) -> Error(e) 174 + } 175 + } 176 + 177 + /// Decoder for LabelDefinition 178 + fn label_definition_decoder() -> decode.Decoder(LabelDefinition) { 179 + use val <- decode.field(0, decode.string) 180 + use description <- decode.field(1, decode.string) 181 + use severity <- decode.field(2, decode.string) 182 + use created_at <- decode.field(3, decode.string) 183 + decode.success(LabelDefinition(val:, description:, severity:, created_at:)) 184 + } 185 + ``` 186 + 187 + **Step 2: Verify module compiles** 188 + 189 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 190 + Expected: Build succeeds 191 + 192 + **Step 3: Commit** 193 + 194 + ```bash 195 + git add server/src/database/repositories/label_definitions.gleam 196 + git commit -m "feat(repo): add label_definitions repository" 197 + ``` 198 + 199 + --- 200 + 201 + ## Task 3: Label Repository 202 + 203 + **Files:** 204 + - Create: `server/src/database/repositories/labels.gleam` 205 + 206 + **Step 1: Write the repository module** 207 + 208 + ```gleam 209 + /// Repository for labels 210 + import database/executor.{type DbError, type Executor, type Value, Int, Text} 211 + import gleam/dynamic/decode 212 + import gleam/int 213 + import gleam/list 214 + import gleam/option.{type Option, None, Some} 215 + import gleam/result 216 + import gleam/string 217 + 218 + /// Label domain type 219 + pub type Label { 220 + Label( 221 + id: Int, 222 + src: String, 223 + uri: String, 224 + cid: Option(String), 225 + val: String, 226 + neg: Bool, 227 + cts: String, 228 + exp: Option(String), 229 + ) 230 + } 231 + 232 + /// Insert a new label 233 + pub fn insert( 234 + exec: Executor, 235 + src: String, 236 + uri: String, 237 + cid: Option(String), 238 + val: String, 239 + exp: Option(String), 240 + ) -> Result(Label, DbError) { 241 + let p1 = executor.placeholder(exec, 1) 242 + let p2 = executor.placeholder(exec, 2) 243 + let p3 = executor.placeholder(exec, 3) 244 + let p4 = executor.placeholder(exec, 4) 245 + let p5 = executor.placeholder(exec, 5) 246 + 247 + let cid_value = case cid { 248 + Some(c) -> Text(c) 249 + None -> executor.null_value() 250 + } 251 + let exp_value = case exp { 252 + Some(e) -> Text(e) 253 + None -> executor.null_value() 254 + } 255 + 256 + let sql = "INSERT INTO label (src, uri, cid, val, exp) VALUES (" <> p1 <> ", " <> p2 <> ", " <> p3 <> ", " <> p4 <> ", " <> p5 <> ") RETURNING id, src, uri, cid, val, neg, cts, exp" 257 + 258 + case executor.query(exec, sql, [Text(src), Text(uri), cid_value, Text(val), exp_value], label_decoder()) { 259 + Ok([label]) -> Ok(label) 260 + Ok(_) -> Error(executor.QueryError("Insert did not return label")) 261 + Error(e) -> Error(e) 262 + } 263 + } 264 + 265 + /// Insert a negation label (retraction) 266 + pub fn insert_negation( 267 + exec: Executor, 268 + src: String, 269 + uri: String, 270 + val: String, 271 + ) -> Result(Label, DbError) { 272 + let p1 = executor.placeholder(exec, 1) 273 + let p2 = executor.placeholder(exec, 2) 274 + let p3 = executor.placeholder(exec, 3) 275 + 276 + let sql = "INSERT INTO label (src, uri, val, neg) VALUES (" <> p1 <> ", " <> p2 <> ", " <> p3 <> ", 1) RETURNING id, src, uri, cid, val, neg, cts, exp" 277 + 278 + case executor.query(exec, sql, [Text(src), Text(uri), Text(val)], label_decoder()) { 279 + Ok([label]) -> Ok(label) 280 + Ok(_) -> Error(executor.QueryError("Insert did not return label")) 281 + Error(e) -> Error(e) 282 + } 283 + } 284 + 285 + /// Get labels for a list of URIs (batch fetch for GraphQL) 286 + /// Returns only active labels (non-negated, non-expired) 287 + pub fn get_by_uris(exec: Executor, uris: List(String)) -> Result(List(Label), DbError) { 288 + case uris { 289 + [] -> Ok([]) 290 + _ -> { 291 + let placeholders = executor.placeholders(exec, list.length(uris), 1) 292 + let exp_check = case executor.dialect(exec) { 293 + executor.SQLite -> "(exp IS NULL OR exp > datetime('now'))" 294 + executor.PostgreSQL -> "(exp IS NULL OR exp::timestamp > NOW())" 295 + } 296 + let sql = "SELECT id, src, uri, cid, val, neg, cts, exp FROM label WHERE uri IN (" <> placeholders <> ") AND neg = 0 AND " <> exp_check <> " ORDER BY cts DESC" 297 + executor.query(exec, sql, list.map(uris, Text), label_decoder()) 298 + } 299 + } 300 + } 301 + 302 + /// Get all labels (admin query with optional filters) 303 + pub fn get_all( 304 + exec: Executor, 305 + uri_filter: Option(String), 306 + val_filter: Option(String), 307 + limit: Int, 308 + cursor: Option(Int), 309 + ) -> Result(List(Label), DbError) { 310 + let mut_where_parts: List(String) = [] 311 + let mut_params: List(Value) = [] 312 + let mut_param_count = 0 313 + 314 + // Add URI filter if provided 315 + let #(where_parts, params, param_count) = case uri_filter { 316 + Some(uri) -> { 317 + let p = executor.placeholder(exec, mut_param_count + 1) 318 + #([p <> " = uri", ..mut_where_parts], [Text(uri), ..mut_params], mut_param_count + 1) 319 + } 320 + None -> #(mut_where_parts, mut_params, mut_param_count) 321 + } 322 + 323 + // Add val filter if provided 324 + let #(where_parts2, params2, param_count2) = case val_filter { 325 + Some(v) -> { 326 + let p = executor.placeholder(exec, param_count + 1) 327 + #(["val = " <> p, ..where_parts], [Text(v), ..params], param_count + 1) 328 + } 329 + None -> #(where_parts, params, param_count) 330 + } 331 + 332 + // Add cursor filter if provided 333 + let #(where_parts3, params3, param_count3) = case cursor { 334 + Some(c) -> { 335 + let p = executor.placeholder(exec, param_count2 + 1) 336 + #(["id < " <> p, ..where_parts2], [Int(c), ..params2], param_count2 + 1) 337 + } 338 + None -> #(where_parts2, params2, param_count2) 339 + } 340 + 341 + let where_clause = case where_parts3 { 342 + [] -> "" 343 + parts -> " WHERE " <> string.join(list.reverse(parts), " AND ") 344 + } 345 + 346 + let limit_p = executor.placeholder(exec, param_count3 + 1) 347 + let sql = "SELECT id, src, uri, cid, val, neg, cts, exp FROM label" <> where_clause <> " ORDER BY id DESC LIMIT " <> limit_p 348 + 349 + executor.query(exec, sql, list.append(list.reverse(params3), [Int(limit)]), label_decoder()) 350 + } 351 + 352 + /// Check if a URI has an active takedown label 353 + pub fn has_takedown(exec: Executor, uri: String) -> Result(Bool, DbError) { 354 + let exp_check = case executor.dialect(exec) { 355 + executor.SQLite -> "(exp IS NULL OR exp > datetime('now'))" 356 + executor.PostgreSQL -> "(exp IS NULL OR exp::timestamp > NOW())" 357 + } 358 + let p1 = executor.placeholder(exec, 1) 359 + let sql = "SELECT 1 FROM label WHERE uri = " <> p1 <> " AND val IN ('!takedown', '!suspend') AND neg = 0 AND " <> exp_check <> " LIMIT 1" 360 + 361 + case executor.query(exec, sql, [Text(uri)], decode.dynamic) { 362 + Ok([_]) -> Ok(True) 363 + Ok([]) -> Ok(False) 364 + Error(e) -> Error(e) 365 + } 366 + } 367 + 368 + /// Batch check for takedown labels on multiple URIs 369 + /// Returns list of URIs that have active takedown labels 370 + pub fn get_takedown_uris(exec: Executor, uris: List(String)) -> Result(List(String), DbError) { 371 + case uris { 372 + [] -> Ok([]) 373 + _ -> { 374 + let placeholders = executor.placeholders(exec, list.length(uris), 1) 375 + let exp_check = case executor.dialect(exec) { 376 + executor.SQLite -> "(exp IS NULL OR exp > datetime('now'))" 377 + executor.PostgreSQL -> "(exp IS NULL OR exp::timestamp > NOW())" 378 + } 379 + let sql = "SELECT DISTINCT uri FROM label WHERE uri IN (" <> placeholders <> ") AND val IN ('!takedown', '!suspend') AND neg = 0 AND " <> exp_check 380 + 381 + let uri_decoder = { 382 + use uri <- decode.field(0, decode.string) 383 + decode.success(uri) 384 + } 385 + 386 + executor.query(exec, sql, list.map(uris, Text), uri_decoder) 387 + } 388 + } 389 + } 390 + 391 + /// Decoder for Label 392 + fn label_decoder() -> decode.Decoder(Label) { 393 + use id <- decode.field(0, decode.int) 394 + use src <- decode.field(1, decode.string) 395 + use uri <- decode.field(2, decode.string) 396 + use cid <- decode.field(3, decode.optional(decode.string)) 397 + use val <- decode.field(4, decode.string) 398 + use neg_int <- decode.field(5, decode.int) 399 + use cts <- decode.field(6, decode.string) 400 + use exp <- decode.field(7, decode.optional(decode.string)) 401 + let neg = neg_int == 1 402 + decode.success(Label(id:, src:, uri:, cid:, val:, neg:, cts:, exp:)) 403 + } 404 + ``` 405 + 406 + **Step 2: Verify module compiles** 407 + 408 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 409 + Expected: Build succeeds 410 + 411 + **Step 3: Commit** 412 + 413 + ```bash 414 + git add server/src/database/repositories/labels.gleam 415 + git commit -m "feat(repo): add labels repository with takedown filtering" 416 + ``` 417 + 418 + --- 419 + 420 + ## Task 4: Report Repository 421 + 422 + **Files:** 423 + - Create: `server/src/database/repositories/reports.gleam` 424 + 425 + **Step 1: Write the repository module** 426 + 427 + ```gleam 428 + /// Repository for moderation reports 429 + import database/executor.{type DbError, type Executor, type Value, Int, Text} 430 + import gleam/dynamic/decode 431 + import gleam/int 432 + import gleam/list 433 + import gleam/option.{type Option, None, Some} 434 + import gleam/string 435 + 436 + /// Report domain type 437 + pub type Report { 438 + Report( 439 + id: Int, 440 + reporter_did: String, 441 + subject_uri: String, 442 + reason_type: String, 443 + reason: Option(String), 444 + status: String, 445 + resolved_by: Option(String), 446 + resolved_at: Option(String), 447 + created_at: String, 448 + ) 449 + } 450 + 451 + /// Insert a new report 452 + pub fn insert( 453 + exec: Executor, 454 + reporter_did: String, 455 + subject_uri: String, 456 + reason_type: String, 457 + reason: Option(String), 458 + ) -> Result(Report, DbError) { 459 + let p1 = executor.placeholder(exec, 1) 460 + let p2 = executor.placeholder(exec, 2) 461 + let p3 = executor.placeholder(exec, 3) 462 + let p4 = executor.placeholder(exec, 4) 463 + 464 + let reason_value = case reason { 465 + Some(r) -> Text(r) 466 + None -> executor.null_value() 467 + } 468 + 469 + let sql = "INSERT INTO report (reporter_did, subject_uri, reason_type, reason) VALUES (" <> p1 <> ", " <> p2 <> ", " <> p3 <> ", " <> p4 <> ") RETURNING id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at, created_at" 470 + 471 + case executor.query(exec, sql, [Text(reporter_did), Text(subject_uri), Text(reason_type), reason_value], report_decoder()) { 472 + Ok([report]) -> Ok(report) 473 + Ok(_) -> Error(executor.QueryError("Insert did not return report")) 474 + Error(e) -> Error(e) 475 + } 476 + } 477 + 478 + /// Get a report by ID 479 + pub fn get(exec: Executor, id: Int) -> Result(Option(Report), DbError) { 480 + let sql = "SELECT id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at, created_at FROM report WHERE id = " <> executor.placeholder(exec, 1) 481 + case executor.query(exec, sql, [Int(id)], report_decoder()) { 482 + Ok([report]) -> Ok(Some(report)) 483 + Ok(_) -> Ok(None) 484 + Error(e) -> Error(e) 485 + } 486 + } 487 + 488 + /// Get reports with optional status filter and pagination 489 + pub fn get_all( 490 + exec: Executor, 491 + status_filter: Option(String), 492 + limit: Int, 493 + cursor: Option(Int), 494 + ) -> Result(List(Report), DbError) { 495 + let mut_where_parts: List(String) = [] 496 + let mut_params: List(Value) = [] 497 + let mut_param_count = 0 498 + 499 + // Add status filter if provided 500 + let #(where_parts, params, param_count) = case status_filter { 501 + Some(s) -> { 502 + let p = executor.placeholder(exec, mut_param_count + 1) 503 + #(["status = " <> p], [Text(s)], mut_param_count + 1) 504 + } 505 + None -> #(mut_where_parts, mut_params, mut_param_count) 506 + } 507 + 508 + // Add cursor filter if provided 509 + let #(where_parts2, params2, param_count2) = case cursor { 510 + Some(c) -> { 511 + let p = executor.placeholder(exec, param_count + 1) 512 + #(["id < " <> p, ..where_parts], [Int(c), ..params], param_count + 1) 513 + } 514 + None -> #(where_parts, params, param_count) 515 + } 516 + 517 + let where_clause = case where_parts2 { 518 + [] -> "" 519 + parts -> " WHERE " <> string.join(list.reverse(parts), " AND ") 520 + } 521 + 522 + let limit_p = executor.placeholder(exec, param_count2 + 1) 523 + let sql = "SELECT id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at, created_at FROM report" <> where_clause <> " ORDER BY id DESC LIMIT " <> limit_p 524 + 525 + executor.query(exec, sql, list.append(list.reverse(params2), [Int(limit)]), report_decoder()) 526 + } 527 + 528 + /// Resolve a report (apply label or dismiss) 529 + pub fn resolve( 530 + exec: Executor, 531 + id: Int, 532 + status: String, 533 + resolved_by: String, 534 + ) -> Result(Report, DbError) { 535 + let p1 = executor.placeholder(exec, 1) 536 + let p2 = executor.placeholder(exec, 2) 537 + let p3 = executor.placeholder(exec, 3) 538 + 539 + let resolved_at_expr = case executor.dialect(exec) { 540 + executor.SQLite -> "datetime('now')" 541 + executor.PostgreSQL -> "NOW()::text" 542 + } 543 + 544 + let sql = "UPDATE report SET status = " <> p1 <> ", resolved_by = " <> p2 <> ", resolved_at = " <> resolved_at_expr <> " WHERE id = " <> p3 <> " RETURNING id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at, created_at" 545 + 546 + case executor.query(exec, sql, [Text(status), Text(resolved_by), Int(id)], report_decoder()) { 547 + Ok([report]) -> Ok(report) 548 + Ok(_) -> Error(executor.QueryError("Update did not return report")) 549 + Error(e) -> Error(e) 550 + } 551 + } 552 + 553 + /// Decoder for Report 554 + fn report_decoder() -> decode.Decoder(Report) { 555 + use id <- decode.field(0, decode.int) 556 + use reporter_did <- decode.field(1, decode.string) 557 + use subject_uri <- decode.field(2, decode.string) 558 + use reason_type <- decode.field(3, decode.string) 559 + use reason <- decode.field(4, decode.optional(decode.string)) 560 + use status <- decode.field(5, decode.string) 561 + use resolved_by <- decode.field(6, decode.optional(decode.string)) 562 + use resolved_at <- decode.field(7, decode.optional(decode.string)) 563 + use created_at <- decode.field(8, decode.string) 564 + decode.success(Report(id:, reporter_did:, subject_uri:, reason_type:, reason:, status:, resolved_by:, resolved_at:, created_at:)) 565 + } 566 + ``` 567 + 568 + **Step 2: Verify module compiles** 569 + 570 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 571 + Expected: Build succeeds 572 + 573 + **Step 3: Commit** 574 + 575 + ```bash 576 + git add server/src/database/repositories/reports.gleam 577 + git commit -m "feat(repo): add reports repository" 578 + ``` 579 + 580 + --- 581 + 582 + ## Task 5: Admin GraphQL Types 583 + 584 + **Files:** 585 + - Modify: `server/src/graphql/admin/types.gleam` 586 + 587 + **Step 1: Add label and report types to types.gleam** 588 + 589 + Add these types after the existing types: 590 + 591 + ```gleam 592 + /// LabelSeverity enum for label definitions 593 + pub fn label_severity_enum() -> schema.Type { 594 + schema.enum_type("LabelSeverity", "Severity level of a label", [ 595 + schema.enum_value("INFORM", "Informational, client can show indicator"), 596 + schema.enum_value("ALERT", "Client should warn/blur"), 597 + schema.enum_value("TAKEDOWN", "Server filters, content not returned"), 598 + ]) 599 + } 600 + 601 + /// LabelDefinition type 602 + pub fn label_definition_type() -> schema.Type { 603 + schema.object_type("LabelDefinition", "Label value definition", [ 604 + schema.field( 605 + "val", 606 + schema.non_null(schema.string_type()), 607 + "Label value (e.g., 'porn', '!takedown')", 608 + fn(ctx) { Ok(get_field(ctx, "val")) }, 609 + ), 610 + schema.field( 611 + "description", 612 + schema.non_null(schema.string_type()), 613 + "Human-readable description", 614 + fn(ctx) { Ok(get_field(ctx, "description")) }, 615 + ), 616 + schema.field( 617 + "severity", 618 + schema.non_null(label_severity_enum()), 619 + "Severity level", 620 + fn(ctx) { Ok(get_field(ctx, "severity")) }, 621 + ), 622 + schema.field( 623 + "createdAt", 624 + schema.non_null(schema.string_type()), 625 + "Creation timestamp", 626 + fn(ctx) { Ok(get_field(ctx, "createdAt")) }, 627 + ), 628 + ]) 629 + } 630 + 631 + /// Label type 632 + pub fn label_type() -> schema.Type { 633 + schema.object_type("Label", "Applied label on a record or account", [ 634 + schema.field( 635 + "id", 636 + schema.non_null(schema.int_type()), 637 + "Label ID", 638 + fn(ctx) { Ok(get_field(ctx, "id")) }, 639 + ), 640 + schema.field( 641 + "src", 642 + schema.non_null(schema.string_type()), 643 + "DID of admin who applied the label", 644 + fn(ctx) { Ok(get_field(ctx, "src")) }, 645 + ), 646 + schema.field( 647 + "uri", 648 + schema.non_null(schema.string_type()), 649 + "Subject URI (at:// or did:)", 650 + fn(ctx) { Ok(get_field(ctx, "uri")) }, 651 + ), 652 + schema.field( 653 + "cid", 654 + schema.string_type(), 655 + "Optional CID for version-specific label", 656 + fn(ctx) { Ok(get_field(ctx, "cid")) }, 657 + ), 658 + schema.field( 659 + "val", 660 + schema.non_null(schema.string_type()), 661 + "Label value", 662 + fn(ctx) { Ok(get_field(ctx, "val")) }, 663 + ), 664 + schema.field( 665 + "neg", 666 + schema.non_null(schema.boolean_type()), 667 + "True if this is a negation (retraction)", 668 + fn(ctx) { Ok(get_field(ctx, "neg")) }, 669 + ), 670 + schema.field( 671 + "cts", 672 + schema.non_null(schema.string_type()), 673 + "Creation timestamp", 674 + fn(ctx) { Ok(get_field(ctx, "cts")) }, 675 + ), 676 + schema.field( 677 + "exp", 678 + schema.string_type(), 679 + "Optional expiration timestamp", 680 + fn(ctx) { Ok(get_field(ctx, "exp")) }, 681 + ), 682 + ]) 683 + } 684 + 685 + /// ReportReasonType enum 686 + pub fn report_reason_type_enum() -> schema.Type { 687 + schema.enum_type("ReportReasonType", "Reason for submitting a report", [ 688 + schema.enum_value("SPAM", "Spam or unwanted content"), 689 + schema.enum_value("VIOLATION", "Violates terms of service"), 690 + schema.enum_value("MISLEADING", "Misleading or false information"), 691 + schema.enum_value("SEXUAL", "Inappropriate sexual content"), 692 + schema.enum_value("RUDE", "Rude or abusive behavior"), 693 + schema.enum_value("OTHER", "Other reason"), 694 + ]) 695 + } 696 + 697 + /// ReportStatus enum 698 + pub fn report_status_enum() -> schema.Type { 699 + schema.enum_type("ReportStatus", "Status of a moderation report", [ 700 + schema.enum_value("PENDING", "Awaiting review"), 701 + schema.enum_value("RESOLVED", "Resolved with action"), 702 + schema.enum_value("DISMISSED", "Dismissed without action"), 703 + ]) 704 + } 705 + 706 + /// ReportAction enum for resolving reports 707 + pub fn report_action_enum() -> schema.Type { 708 + schema.enum_type("ReportAction", "Action to take when resolving a report", [ 709 + schema.enum_value("APPLY_LABEL", "Apply a label to the subject"), 710 + schema.enum_value("DISMISS", "Dismiss the report without action"), 711 + ]) 712 + } 713 + 714 + /// Report type 715 + pub fn report_type() -> schema.Type { 716 + schema.object_type("Report", "User-submitted moderation report", [ 717 + schema.field( 718 + "id", 719 + schema.non_null(schema.int_type()), 720 + "Report ID", 721 + fn(ctx) { Ok(get_field(ctx, "id")) }, 722 + ), 723 + schema.field( 724 + "reporterDid", 725 + schema.non_null(schema.string_type()), 726 + "DID of reporter", 727 + fn(ctx) { Ok(get_field(ctx, "reporterDid")) }, 728 + ), 729 + schema.field( 730 + "subjectUri", 731 + schema.non_null(schema.string_type()), 732 + "Subject URI (at:// or did:)", 733 + fn(ctx) { Ok(get_field(ctx, "subjectUri")) }, 734 + ), 735 + schema.field( 736 + "reasonType", 737 + schema.non_null(report_reason_type_enum()), 738 + "Reason type", 739 + fn(ctx) { Ok(get_field(ctx, "reasonType")) }, 740 + ), 741 + schema.field( 742 + "reason", 743 + schema.string_type(), 744 + "Optional free-text explanation", 745 + fn(ctx) { Ok(get_field(ctx, "reason")) }, 746 + ), 747 + schema.field( 748 + "status", 749 + schema.non_null(report_status_enum()), 750 + "Report status", 751 + fn(ctx) { Ok(get_field(ctx, "status")) }, 752 + ), 753 + schema.field( 754 + "resolvedBy", 755 + schema.string_type(), 756 + "DID of admin who resolved", 757 + fn(ctx) { Ok(get_field(ctx, "resolvedBy")) }, 758 + ), 759 + schema.field( 760 + "resolvedAt", 761 + schema.string_type(), 762 + "Resolution timestamp", 763 + fn(ctx) { Ok(get_field(ctx, "resolvedAt")) }, 764 + ), 765 + schema.field( 766 + "createdAt", 767 + schema.non_null(schema.string_type()), 768 + "Creation timestamp", 769 + fn(ctx) { Ok(get_field(ctx, "createdAt")) }, 770 + ), 771 + ]) 772 + } 773 + ``` 774 + 775 + **Step 2: Verify module compiles** 776 + 777 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 778 + Expected: Build succeeds 779 + 780 + **Step 3: Commit** 781 + 782 + ```bash 783 + git add server/src/graphql/admin/types.gleam 784 + git commit -m "feat(graphql): add label and report types to admin API" 785 + ``` 786 + 787 + --- 788 + 789 + ## Task 6: Admin GraphQL Converters 790 + 791 + **Files:** 792 + - Modify: `server/src/graphql/admin/converters.gleam` 793 + 794 + **Step 1: Add converters for label and report types** 795 + 796 + Add these functions to converters.gleam: 797 + 798 + ```gleam 799 + import database/repositories/label_definitions 800 + import database/repositories/labels 801 + import database/repositories/reports 802 + 803 + /// Convert LabelDefinition to GraphQL value 804 + pub fn label_definition_to_value(def: label_definitions.LabelDefinition) -> value.Value { 805 + value.Object([ 806 + #("val", value.String(def.val)), 807 + #("description", value.String(def.description)), 808 + #("severity", value.Enum(string.uppercase(def.severity))), 809 + #("createdAt", value.String(def.created_at)), 810 + ]) 811 + } 812 + 813 + /// Convert Label to GraphQL value 814 + pub fn label_to_value(label: labels.Label) -> value.Value { 815 + let cid_value = case label.cid { 816 + Some(c) -> value.String(c) 817 + None -> value.Null 818 + } 819 + let exp_value = case label.exp { 820 + Some(e) -> value.String(e) 821 + None -> value.Null 822 + } 823 + value.Object([ 824 + #("id", value.Int(label.id)), 825 + #("src", value.String(label.src)), 826 + #("uri", value.String(label.uri)), 827 + #("cid", cid_value), 828 + #("val", value.String(label.val)), 829 + #("neg", value.Boolean(label.neg)), 830 + #("cts", value.String(label.cts)), 831 + #("exp", exp_value), 832 + ]) 833 + } 834 + 835 + /// Convert Report to GraphQL value 836 + pub fn report_to_value(report: reports.Report) -> value.Value { 837 + let reason_value = case report.reason { 838 + Some(r) -> value.String(r) 839 + None -> value.Null 840 + } 841 + let resolved_by_value = case report.resolved_by { 842 + Some(r) -> value.String(r) 843 + None -> value.Null 844 + } 845 + let resolved_at_value = case report.resolved_at { 846 + Some(r) -> value.String(r) 847 + None -> value.Null 848 + } 849 + value.Object([ 850 + #("id", value.Int(report.id)), 851 + #("reporterDid", value.String(report.reporter_did)), 852 + #("subjectUri", value.String(report.subject_uri)), 853 + #("reasonType", value.Enum(string.uppercase(report.reason_type))), 854 + #("reason", reason_value), 855 + #("status", value.Enum(string.uppercase(report.status))), 856 + #("resolvedBy", resolved_by_value), 857 + #("resolvedAt", resolved_at_value), 858 + #("createdAt", value.String(report.created_at)), 859 + ]) 860 + } 861 + ``` 862 + 863 + **Step 2: Add necessary imports at the top** 864 + 865 + ```gleam 866 + import gleam/string 867 + ``` 868 + 869 + **Step 3: Verify module compiles** 870 + 871 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 872 + Expected: Build succeeds 873 + 874 + **Step 4: Commit** 875 + 876 + ```bash 877 + git add server/src/graphql/admin/converters.gleam 878 + git commit -m "feat(graphql): add label and report converters" 879 + ``` 880 + 881 + --- 882 + 883 + ## Task 7: Admin GraphQL Queries 884 + 885 + **Files:** 886 + - Modify: `server/src/graphql/admin/queries.gleam` 887 + 888 + **Step 1: Add imports for new repositories** 889 + 890 + Add to imports: 891 + 892 + ```gleam 893 + import database/repositories/label_definitions 894 + import database/repositories/labels 895 + import database/repositories/reports 896 + ``` 897 + 898 + **Step 2: Add label and report queries to query_type function** 899 + 900 + Add these fields to the query_type object_type list: 901 + 902 + ```gleam 903 + // labelDefinitions query 904 + schema.field( 905 + "labelDefinitions", 906 + schema.non_null( 907 + schema.list_type(schema.non_null(admin_types.label_definition_type())), 908 + ), 909 + "Get all label definitions", 910 + fn(_ctx) { 911 + case label_definitions.get_all(conn) { 912 + Ok(defs) -> 913 + Ok(value.List(list.map(defs, converters.label_definition_to_value))) 914 + Error(_) -> Error("Failed to fetch label definitions") 915 + } 916 + }, 917 + ), 918 + // labels query (admin only) 919 + schema.field_with_args( 920 + "labels", 921 + schema.non_null( 922 + schema.list_type(schema.non_null(admin_types.label_type())), 923 + ), 924 + "Get labels with optional filters (admin only)", 925 + [ 926 + schema.argument("uri", schema.string_type(), "Filter by subject URI", None), 927 + schema.argument("val", schema.string_type(), "Filter by label value", None), 928 + schema.argument("limit", schema.int_type(), "Max results (default 50)", None), 929 + schema.argument("cursor", schema.int_type(), "Cursor for pagination (label ID)", None), 930 + ], 931 + fn(ctx) { 932 + case session.get_current_session(req, conn, did_cache) { 933 + Ok(sess) -> { 934 + case config_repo.is_admin(conn, sess.did) { 935 + True -> { 936 + let uri_filter = case schema.get_argument(ctx, "uri") { 937 + Some(value.String(u)) -> Some(u) 938 + _ -> None 939 + } 940 + let val_filter = case schema.get_argument(ctx, "val") { 941 + Some(value.String(v)) -> Some(v) 942 + _ -> None 943 + } 944 + let limit = case schema.get_argument(ctx, "limit") { 945 + Some(value.Int(l)) -> l 946 + _ -> 50 947 + } 948 + let cursor = case schema.get_argument(ctx, "cursor") { 949 + Some(value.Int(c)) -> Some(c) 950 + _ -> None 951 + } 952 + case labels.get_all(conn, uri_filter, val_filter, limit, cursor) { 953 + Ok(label_list) -> 954 + Ok(value.List(list.map(label_list, converters.label_to_value))) 955 + Error(_) -> Error("Failed to fetch labels") 956 + } 957 + } 958 + False -> Error("Admin privileges required") 959 + } 960 + } 961 + Error(_) -> Error("Authentication required") 962 + } 963 + }, 964 + ), 965 + // reports query (admin only) 966 + schema.field_with_args( 967 + "reports", 968 + schema.non_null( 969 + schema.list_type(schema.non_null(admin_types.report_type())), 970 + ), 971 + "Get moderation reports with optional status filter (admin only)", 972 + [ 973 + schema.argument("status", admin_types.report_status_enum(), "Filter by status", None), 974 + schema.argument("limit", schema.int_type(), "Max results (default 50)", None), 975 + schema.argument("cursor", schema.int_type(), "Cursor for pagination (report ID)", None), 976 + ], 977 + fn(ctx) { 978 + case session.get_current_session(req, conn, did_cache) { 979 + Ok(sess) -> { 980 + case config_repo.is_admin(conn, sess.did) { 981 + True -> { 982 + let status_filter = case schema.get_argument(ctx, "status") { 983 + Some(value.Enum(s)) -> Some(string.lowercase(s)) 984 + _ -> None 985 + } 986 + let limit = case schema.get_argument(ctx, "limit") { 987 + Some(value.Int(l)) -> l 988 + _ -> 50 989 + } 990 + let cursor = case schema.get_argument(ctx, "cursor") { 991 + Some(value.Int(c)) -> Some(c) 992 + _ -> None 993 + } 994 + case reports.get_all(conn, status_filter, limit, cursor) { 995 + Ok(report_list) -> 996 + Ok(value.List(list.map(report_list, converters.report_to_value))) 997 + Error(_) -> Error("Failed to fetch reports") 998 + } 999 + } 1000 + False -> Error("Admin privileges required") 1001 + } 1002 + } 1003 + Error(_) -> Error("Authentication required") 1004 + } 1005 + }, 1006 + ), 1007 + ``` 1008 + 1009 + **Step 3: Add string import if not present** 1010 + 1011 + ```gleam 1012 + import gleam/string 1013 + ``` 1014 + 1015 + **Step 4: Verify module compiles** 1016 + 1017 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 1018 + Expected: Build succeeds 1019 + 1020 + **Step 5: Commit** 1021 + 1022 + ```bash 1023 + git add server/src/graphql/admin/queries.gleam 1024 + git commit -m "feat(graphql): add label and report queries to admin API" 1025 + ``` 1026 + 1027 + --- 1028 + 1029 + ## Task 8: Admin GraphQL Mutations 1030 + 1031 + **Files:** 1032 + - Modify: `server/src/graphql/admin/mutations.gleam` 1033 + 1034 + **Step 1: Add imports for new repositories** 1035 + 1036 + Add to imports: 1037 + 1038 + ```gleam 1039 + import database/repositories/label_definitions 1040 + import database/repositories/labels 1041 + import database/repositories/reports 1042 + ``` 1043 + 1044 + **Step 2: Add label and report mutations to mutation_type function** 1045 + 1046 + Add these fields to the mutation_type object_type list: 1047 + 1048 + ```gleam 1049 + // createLabel mutation (admin only) 1050 + schema.field_with_args( 1051 + "createLabel", 1052 + schema.non_null(admin_types.label_type()), 1053 + "Create a label on a record or account (admin only)", 1054 + [ 1055 + schema.argument("uri", schema.non_null(schema.string_type()), "Subject URI (at:// or did:)", None), 1056 + schema.argument("val", schema.non_null(schema.string_type()), "Label value", None), 1057 + schema.argument("cid", schema.string_type(), "Optional CID for version-specific label", None), 1058 + schema.argument("exp", schema.string_type(), "Optional expiration datetime", None), 1059 + ], 1060 + fn(ctx) { 1061 + case session.get_current_session(req, conn, did_cache) { 1062 + Ok(sess) -> { 1063 + case config_repo.is_admin(conn, sess.did) { 1064 + True -> { 1065 + case schema.get_argument(ctx, "uri"), schema.get_argument(ctx, "val") { 1066 + Some(value.String(uri)), Some(value.String(val)) -> { 1067 + // Validate label value exists 1068 + case label_definitions.exists(conn, val) { 1069 + Ok(True) -> { 1070 + let cid = case schema.get_argument(ctx, "cid") { 1071 + Some(value.String(c)) -> Some(c) 1072 + _ -> None 1073 + } 1074 + let exp = case schema.get_argument(ctx, "exp") { 1075 + Some(value.String(e)) -> Some(e) 1076 + _ -> None 1077 + } 1078 + case labels.insert(conn, sess.did, uri, cid, val, exp) { 1079 + Ok(label) -> Ok(converters.label_to_value(label)) 1080 + Error(_) -> Error("Failed to create label") 1081 + } 1082 + } 1083 + Ok(False) -> Error("Unknown label value: " <> val) 1084 + Error(_) -> Error("Failed to validate label value") 1085 + } 1086 + } 1087 + _, _ -> Error("uri and val are required") 1088 + } 1089 + } 1090 + False -> Error("Admin privileges required") 1091 + } 1092 + } 1093 + Error(_) -> Error("Authentication required") 1094 + } 1095 + }, 1096 + ), 1097 + // negateLabel mutation (admin only) 1098 + schema.field_with_args( 1099 + "negateLabel", 1100 + schema.non_null(admin_types.label_type()), 1101 + "Negate (retract) a label on a record or account (admin only)", 1102 + [ 1103 + schema.argument("uri", schema.non_null(schema.string_type()), "Subject URI", None), 1104 + schema.argument("val", schema.non_null(schema.string_type()), "Label value to negate", None), 1105 + ], 1106 + fn(ctx) { 1107 + case session.get_current_session(req, conn, did_cache) { 1108 + Ok(sess) -> { 1109 + case config_repo.is_admin(conn, sess.did) { 1110 + True -> { 1111 + case schema.get_argument(ctx, "uri"), schema.get_argument(ctx, "val") { 1112 + Some(value.String(uri)), Some(value.String(val)) -> { 1113 + case labels.insert_negation(conn, sess.did, uri, val) { 1114 + Ok(label) -> Ok(converters.label_to_value(label)) 1115 + Error(_) -> Error("Failed to negate label") 1116 + } 1117 + } 1118 + _, _ -> Error("uri and val are required") 1119 + } 1120 + } 1121 + False -> Error("Admin privileges required") 1122 + } 1123 + } 1124 + Error(_) -> Error("Authentication required") 1125 + } 1126 + }, 1127 + ), 1128 + // createLabelDefinition mutation (admin only) 1129 + schema.field_with_args( 1130 + "createLabelDefinition", 1131 + schema.non_null(admin_types.label_definition_type()), 1132 + "Create a custom label definition (admin only)", 1133 + [ 1134 + schema.argument("val", schema.non_null(schema.string_type()), "Label value", None), 1135 + schema.argument("description", schema.non_null(schema.string_type()), "Description", None), 1136 + schema.argument("severity", schema.non_null(admin_types.label_severity_enum()), "Severity level", None), 1137 + ], 1138 + fn(ctx) { 1139 + case session.get_current_session(req, conn, did_cache) { 1140 + Ok(sess) -> { 1141 + case config_repo.is_admin(conn, sess.did) { 1142 + True -> { 1143 + case 1144 + schema.get_argument(ctx, "val"), 1145 + schema.get_argument(ctx, "description"), 1146 + schema.get_argument(ctx, "severity") 1147 + { 1148 + Some(value.String(val)), Some(value.String(desc)), Some(value.Enum(sev)) -> { 1149 + let severity = string.lowercase(sev) 1150 + case label_definitions.insert(conn, val, desc, severity) { 1151 + Ok(_) -> { 1152 + case label_definitions.get(conn, val) { 1153 + Ok(Some(def)) -> Ok(converters.label_definition_to_value(def)) 1154 + _ -> Error("Failed to fetch created definition") 1155 + } 1156 + } 1157 + Error(_) -> Error("Failed to create label definition") 1158 + } 1159 + } 1160 + _, _, _ -> Error("val, description, and severity are required") 1161 + } 1162 + } 1163 + False -> Error("Admin privileges required") 1164 + } 1165 + } 1166 + Error(_) -> Error("Authentication required") 1167 + } 1168 + }, 1169 + ), 1170 + // resolveReport mutation (admin only) 1171 + schema.field_with_args( 1172 + "resolveReport", 1173 + schema.non_null(admin_types.report_type()), 1174 + "Resolve a moderation report (admin only)", 1175 + [ 1176 + schema.argument("id", schema.non_null(schema.int_type()), "Report ID", None), 1177 + schema.argument("action", schema.non_null(admin_types.report_action_enum()), "Action to take", None), 1178 + schema.argument("labelVal", schema.string_type(), "Label value to apply (required if action is APPLY_LABEL)", None), 1179 + ], 1180 + fn(ctx) { 1181 + case session.get_current_session(req, conn, did_cache) { 1182 + Ok(sess) -> { 1183 + case config_repo.is_admin(conn, sess.did) { 1184 + True -> { 1185 + case schema.get_argument(ctx, "id"), schema.get_argument(ctx, "action") { 1186 + Some(value.Int(id)), Some(value.Enum(action)) -> { 1187 + // Get the report first 1188 + case reports.get(conn, id) { 1189 + Ok(Some(report)) -> { 1190 + case action { 1191 + "APPLY_LABEL" -> { 1192 + case schema.get_argument(ctx, "labelVal") { 1193 + Some(value.String(label_val)) -> { 1194 + // Create the label 1195 + case labels.insert(conn, sess.did, report.subject_uri, None, label_val, None) { 1196 + Ok(_) -> { 1197 + // Mark report as resolved 1198 + case reports.resolve(conn, id, "resolved", sess.did) { 1199 + Ok(resolved) -> Ok(converters.report_to_value(resolved)) 1200 + Error(_) -> Error("Failed to resolve report") 1201 + } 1202 + } 1203 + Error(_) -> Error("Failed to apply label") 1204 + } 1205 + } 1206 + _ -> Error("labelVal is required when action is APPLY_LABEL") 1207 + } 1208 + } 1209 + "DISMISS" -> { 1210 + case reports.resolve(conn, id, "dismissed", sess.did) { 1211 + Ok(resolved) -> Ok(converters.report_to_value(resolved)) 1212 + Error(_) -> Error("Failed to dismiss report") 1213 + } 1214 + } 1215 + _ -> Error("Invalid action") 1216 + } 1217 + } 1218 + Ok(None) -> Error("Report not found") 1219 + Error(_) -> Error("Failed to fetch report") 1220 + } 1221 + } 1222 + _, _ -> Error("id and action are required") 1223 + } 1224 + } 1225 + False -> Error("Admin privileges required") 1226 + } 1227 + } 1228 + Error(_) -> Error("Authentication required") 1229 + } 1230 + }, 1231 + ), 1232 + ``` 1233 + 1234 + **Step 3: Verify module compiles** 1235 + 1236 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 1237 + Expected: Build succeeds 1238 + 1239 + **Step 4: Commit** 1240 + 1241 + ```bash 1242 + git add server/src/graphql/admin/mutations.gleam 1243 + git commit -m "feat(graphql): add label and report mutations to admin API" 1244 + ``` 1245 + 1246 + --- 1247 + 1248 + ## Task 9: Lexicon API - createReport Mutation 1249 + 1250 + **Files:** 1251 + - Modify: `server/src/graphql/lexicon/mutations.gleam` 1252 + 1253 + **Step 1: Add report repository import** 1254 + 1255 + ```gleam 1256 + import database/repositories/reports 1257 + ``` 1258 + 1259 + **Step 2: Add createReport mutation** 1260 + 1261 + This needs to be added to the lexicon schema. Look at how mutations are structured in `mutations.gleam` and add a public function that can be called from the schema builder to add the createReport mutation. 1262 + 1263 + The mutation should: 1264 + - Require authentication (get user DID from auth token) 1265 + - Accept subjectUri, reasonType, and optional reason 1266 + - Insert into reports table 1267 + - Return the created report 1268 + 1269 + **Step 3: Verify module compiles** 1270 + 1271 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 1272 + Expected: Build succeeds 1273 + 1274 + **Step 4: Commit** 1275 + 1276 + ```bash 1277 + git add server/src/graphql/lexicon/mutations.gleam 1278 + git commit -m "feat(graphql): add createReport mutation to lexicon API" 1279 + ``` 1280 + 1281 + --- 1282 + 1283 + ## Task 10: Labels Field on Record Types 1284 + 1285 + **Files:** 1286 + - Modify: `server/src/graphql/lexicon/fetchers.gleam` 1287 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 1288 + 1289 + **Step 1: Create label fetcher in fetchers.gleam** 1290 + 1291 + Add a function to create a labels batch fetcher: 1292 + 1293 + ```gleam 1294 + import database/repositories/labels 1295 + 1296 + /// Create a labels fetcher for batch loading labels by URI 1297 + pub fn labels_fetcher(db: Executor) { 1298 + fn(uris: List(String)) -> Result(dict.Dict(String, List(value.Value)), String) { 1299 + case labels.get_by_uris(db, uris) { 1300 + Ok(label_list) -> { 1301 + // Group labels by URI 1302 + let grouped = list.fold(label_list, dict.new(), fn(acc, label) { 1303 + let label_value = value.Object([ 1304 + #("id", value.Int(label.id)), 1305 + #("src", value.String(label.src)), 1306 + #("uri", value.String(label.uri)), 1307 + #("cid", case label.cid { 1308 + Some(c) -> value.String(c) 1309 + None -> value.Null 1310 + }), 1311 + #("val", value.String(label.val)), 1312 + #("neg", value.Boolean(label.neg)), 1313 + #("cts", value.String(label.cts)), 1314 + #("exp", case label.exp { 1315 + Some(e) -> value.String(e) 1316 + None -> value.Null 1317 + }), 1318 + ]) 1319 + let existing = dict.get(acc, label.uri) |> result.unwrap([]) 1320 + dict.insert(acc, label.uri, [label_value, ..existing]) 1321 + }) 1322 + Ok(grouped) 1323 + } 1324 + Error(_) -> Error("Failed to fetch labels") 1325 + } 1326 + } 1327 + } 1328 + ``` 1329 + 1330 + **Step 2: Add labels field to record types in database.gleam** 1331 + 1332 + This requires modifying the schema builder to add a `labels` field to each record type. The field should: 1333 + - Return `[Label!]!` (non-null list of non-null labels) 1334 + - Use the labels fetcher to batch load labels for the record's URI 1335 + - Be added to every record object type 1336 + 1337 + **Step 3: Verify module compiles** 1338 + 1339 + Run: `cd /Users/chadmiller/code/quickslice && gleam build` 1340 + Expected: Build succeeds 1341 + 1342 + **Step 4: Commit** 1343 + 1344 + ```bash 1345 + git add server/src/graphql/lexicon/fetchers.gleam lexicon_graphql/src/lexicon_graphql/schema/database.gleam 1346 + git commit -m "feat(graphql): add labels field to all record types" 1347 + ``` 1348 + 1349 + --- 1350 + 1351 + ## Task 11: Takedown Filtering in Record Fetchers 1352 + 1353 + **Files:** 1354 + - Modify: `server/src/graphql/lexicon/fetchers.gleam` 1355 + 1356 + **Step 1: Add takedown filtering to record_fetcher** 1357 + 1358 + Modify the record_fetcher to: 1359 + 1. After fetching records, get the list of URIs 1360 + 2. Call `labels.get_takedown_uris(db, uris)` to find which have takedown labels 1361 + 3. Filter out records with takedown URIs from the result 1362 + 1363 + **Step 2: Add takedown filtering to batch_fetcher** 1364 + 1365 + Similar modification for batch fetches. 1366 + 1367 + **Step 3: Verify module compiles** 1368 + 1369 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 1370 + Expected: Build succeeds 1371 + 1372 + **Step 4: Commit** 1373 + 1374 + ```bash 1375 + git add server/src/graphql/lexicon/fetchers.gleam 1376 + git commit -m "feat(moderation): filter takedown-labeled records from queries" 1377 + ``` 1378 + 1379 + --- 1380 + 1381 + ## Task 12: Integration Testing 1382 + 1383 + **Files:** 1384 + - Create: `server/test/labels_test.gleam` (if test framework exists) 1385 + 1386 + **Step 1: Test label creation** 1387 + 1388 + Test that: 1389 + - Admin can create a label 1390 + - Non-admin cannot create a label 1391 + - Label appears on record query 1392 + 1393 + **Step 2: Test report workflow** 1394 + 1395 + Test that: 1396 + - Authenticated user can create a report 1397 + - Admin can view reports 1398 + - Admin can resolve report with label 1399 + - Admin can dismiss report 1400 + 1401 + **Step 3: Test takedown filtering** 1402 + 1403 + Test that: 1404 + - Record with !takedown label is not returned in queries 1405 + - Record is still accessible with admin includeRemoved flag (if implemented) 1406 + 1407 + **Step 4: Run full test suite** 1408 + 1409 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1410 + Expected: All tests pass 1411 + 1412 + **Step 5: Commit** 1413 + 1414 + ```bash 1415 + git add server/test/ 1416 + git commit -m "test: add labels and reports integration tests" 1417 + ``` 1418 + 1419 + --- 1420 + 1421 + ## Summary 1422 + 1423 + This plan implements: 1424 + 1425 + 1. **Database Layer** (Tasks 1-4): Migration and repositories for label_definition, label, and report tables 1426 + 2. **Admin API** (Tasks 5-8): GraphQL types, queries, and mutations for admin management 1427 + 3. **Lexicon API** (Task 9): createReport mutation for user submissions 1428 + 4. **Labels Field** (Task 10): Add labels to all record types for client display 1429 + 5. **Takedown Filtering** (Task 11): Server-side filtering of !takedown/!suspend content 1430 + 6. **Testing** (Task 12): Integration tests for the full workflow 1431 + 1432 + Total: 12 tasks with ~40 individual steps.
+645
dev-docs/plans/2025-12-29-self-labels-support.md
··· 1 + # Self-Labels Support Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Merge self-labels from record JSON with moderator labels, avoiding field name collisions. 6 + 7 + **Architecture:** The `labels` field resolver checks if the record's JSON contains self-labels (`com.atproto.label.defs#selfLabels`), parses them, fetches moderator labels from the database, and returns the merged list. If a lexicon defines a `labels` property, we replace its resolver with our enhanced version instead of adding a duplicate field. 8 + 9 + **Tech Stack:** Gleam, lexicon_graphql library, GraphQL schema builder 10 + 11 + --- 12 + 13 + ## Background 14 + 15 + AT Protocol supports two label sources: 16 + 1. **Self-labels** - embedded in record JSON by the author (e.g., marking own post as adult content) 17 + 2. **Moderator labels** - applied externally by labelers, stored in our `label` table 18 + 19 + Currently, our `labels` field only returns moderator labels. If a lexicon defines `labels` with type `com.atproto.label.defs#selfLabels`, we'd have a field collision. 20 + 21 + ## Tasks 22 + 23 + ### Task 1: Add Helper to Check for Self-Labels Property 24 + 25 + **Files:** 26 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 27 + 28 + **Step 1: Write the helper function** 29 + 30 + Add after line 3436 (after `build_labels_field`): 31 + 32 + ```gleam 33 + /// Check if a record type has a labels property with selfLabels ref 34 + fn has_self_labels_property(properties: List(#(String, types.Property))) -> Bool { 35 + list.any(properties, fn(prop) { 36 + let #(name, types.Property(_, _, _, ref, _, _)) = prop 37 + name == "labels" && ref == option.Some("com.atproto.label.defs#selfLabels") 38 + }) 39 + } 40 + ``` 41 + 42 + **Step 2: Verify it compiles** 43 + 44 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 45 + Expected: Compiles without errors 46 + 47 + **Step 3: Commit** 48 + 49 + ```bash 50 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 51 + git commit -m "feat(lexicon_graphql): add helper to detect selfLabels property" 52 + ``` 53 + 54 + --- 55 + 56 + ### Task 2: Modify build_labels_field to Accept Properties 57 + 58 + **Files:** 59 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 60 + 61 + **Step 1: Update function signature** 62 + 63 + Change `build_labels_field` (around line 3402) from: 64 + 65 + ```gleam 66 + fn build_labels_field( 67 + labels_fetcher: option.Option(LabelsFetcher), 68 + ) -> List(schema.Field) { 69 + ``` 70 + 71 + To: 72 + 73 + ```gleam 74 + fn build_labels_field( 75 + labels_fetcher: option.Option(LabelsFetcher), 76 + properties: List(#(String, types.Property)), 77 + ) -> List(schema.Field) { 78 + ``` 79 + 80 + **Step 2: Update the call site** 81 + 82 + Find where `build_labels_field` is called (around line 650) and change: 83 + 84 + ```gleam 85 + let labels_field = build_labels_field(labels_fetcher) 86 + ``` 87 + 88 + To: 89 + 90 + ```gleam 91 + let labels_field = build_labels_field(labels_fetcher, record_type.properties) 92 + ``` 93 + 94 + **Step 3: Verify it compiles** 95 + 96 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 97 + Expected: Compiles without errors 98 + 99 + **Step 4: Commit** 100 + 101 + ```bash 102 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 103 + git commit -m "refactor(lexicon_graphql): pass properties to build_labels_field" 104 + ``` 105 + 106 + --- 107 + 108 + ### Task 3: Add Self-Labels Parsing to Fetcher 109 + 110 + **Files:** 111 + - Modify: `server/src/graphql/lexicon/fetchers.gleam` 112 + 113 + **Step 1: Add helper to parse self-labels from JSON** 114 + 115 + Add after the `filter_takedowns` function (around line 39): 116 + 117 + ```gleam 118 + /// Parse self-labels from a record's JSON if present 119 + /// Self-labels have format: {"$type": "com.atproto.label.defs#selfLabels", "values": [{"val": "..."}]} 120 + fn parse_self_labels_from_json( 121 + json_str: String, 122 + uri: String, 123 + ) -> List(value.Value) { 124 + case json.parse(json_str, dynamic.dynamic) { 125 + Error(_) -> [] 126 + Ok(dyn) -> { 127 + // Try to get labels field 128 + case dynamic.field("labels", dynamic.dynamic)(dyn) { 129 + Error(_) -> [] 130 + Ok(labels_dyn) -> { 131 + // Check $type is selfLabels 132 + case dynamic.field("$type", dynamic.string)(labels_dyn) { 133 + Ok("com.atproto.label.defs#selfLabels") -> { 134 + // Parse values array 135 + case 136 + dynamic.field( 137 + "values", 138 + dynamic.list(dynamic.decode1( 139 + fn(val) { val }, 140 + dynamic.field("val", dynamic.string), 141 + )), 142 + )(labels_dyn) 143 + { 144 + Ok(vals) -> { 145 + list.map(vals, fn(val) { 146 + value.Object([ 147 + #("val", value.String(val)), 148 + #("src", value.String(uri |> string.split("/") |> list.first |> result.unwrap(""))), 149 + #("uri", value.String(uri)), 150 + #("neg", value.Boolean(False)), 151 + #("cts", value.Null), 152 + #("exp", value.Null), 153 + #("cid", value.Null), 154 + #("id", value.Null), 155 + ]) 156 + }) 157 + } 158 + Error(_) -> [] 159 + } 160 + } 161 + _ -> [] 162 + } 163 + } 164 + } 165 + } 166 + } 167 + } 168 + ``` 169 + 170 + **Step 2: Add required imports** 171 + 172 + Add to imports at top of file: 173 + 174 + ```gleam 175 + import gleam/json 176 + import gleam/dynamic 177 + ``` 178 + 179 + **Step 3: Verify it compiles** 180 + 181 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 182 + Expected: Compiles without errors 183 + 184 + **Step 4: Commit** 185 + 186 + ```bash 187 + git add server/src/graphql/lexicon/fetchers.gleam 188 + git commit -m "feat(fetchers): add self-labels JSON parser" 189 + ``` 190 + 191 + --- 192 + 193 + ### Task 4: Modify Labels Fetcher to Accept Record JSON 194 + 195 + **Files:** 196 + - Modify: `server/src/graphql/lexicon/fetchers.gleam` 197 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 198 + 199 + **Step 1: Change fetcher type to include record JSON** 200 + 201 + In `lexicon_graphql/src/lexicon_graphql/schema/database.gleam`, find `LabelsFetcher` type (around line 144): 202 + 203 + ```gleam 204 + pub type LabelsFetcher = 205 + fn(List(String)) -> Result(dict.Dict(String, List(value.Value)), String) 206 + ``` 207 + 208 + Change to: 209 + 210 + ```gleam 211 + pub type LabelsFetcher = 212 + fn(List(#(String, option.Option(String)))) -> 213 + Result(dict.Dict(String, List(value.Value)), String) 214 + ``` 215 + 216 + The tuple is `#(uri, optional_record_json)`. 217 + 218 + **Step 2: Update fetcher in server** 219 + 220 + In `server/src/graphql/lexicon/fetchers.gleam`, change `labels_fetcher` (around line 469): 221 + 222 + ```gleam 223 + pub fn labels_fetcher(db: Executor) { 224 + fn( 225 + uris_with_json: List(#(String, option.Option(String))), 226 + ) -> Result(dict.Dict(String, List(value.Value)), String) { 227 + let uris = list.map(uris_with_json, fn(pair) { pair.0 }) 228 + 229 + case labels.get_by_uris(db, uris) { 230 + Ok(label_list) -> { 231 + // Group moderator labels by URI 232 + let mod_labels = 233 + list.fold(label_list, dict.new(), fn(acc, label) { 234 + let label_value = 235 + value.Object([ 236 + #("id", value.Int(label.id)), 237 + #("src", value.String(label.src)), 238 + #("uri", value.String(label.uri)), 239 + #("cid", case label.cid { 240 + option.Some(c) -> value.String(c) 241 + option.None -> value.Null 242 + }), 243 + #("val", value.String(label.val)), 244 + #("neg", value.Boolean(label.neg)), 245 + #("cts", value.String(label.cts)), 246 + #("exp", case label.exp { 247 + option.Some(e) -> value.String(e) 248 + option.None -> value.Null 249 + }), 250 + ]) 251 + let existing = dict.get(acc, label.uri) |> result.unwrap([]) 252 + dict.insert(acc, label.uri, [label_value, ..existing]) 253 + }) 254 + 255 + // Merge with self-labels from record JSON 256 + let merged = 257 + list.fold(uris_with_json, mod_labels, fn(acc, pair) { 258 + let #(uri, json_opt) = pair 259 + case json_opt { 260 + option.None -> acc 261 + option.Some(json_str) -> { 262 + let self_labels = parse_self_labels_from_json(json_str, uri) 263 + case self_labels { 264 + [] -> acc 265 + _ -> { 266 + let existing = dict.get(acc, uri) |> result.unwrap([]) 267 + dict.insert(acc, uri, list.append(self_labels, existing)) 268 + } 269 + } 270 + } 271 + } 272 + }) 273 + 274 + Ok(merged) 275 + } 276 + Error(_) -> Error("Failed to fetch labels") 277 + } 278 + } 279 + } 280 + ``` 281 + 282 + **Step 3: Update call site in build_labels_field** 283 + 284 + In `lexicon_graphql/src/lexicon_graphql/schema/database.gleam`, update the resolver (around line 3414): 285 + 286 + ```gleam 287 + fn(ctx) { 288 + // Get the URI and JSON from the parent record 289 + case get_field_from_context(ctx, "uri") { 290 + Ok(uri_str) -> { 291 + let json_opt = case get_field_from_context(ctx, "json") { 292 + Ok(j) -> option.Some(j) 293 + Error(_) -> option.None 294 + } 295 + case fetcher([#(uri_str, json_opt)]) { 296 + Ok(results) -> { 297 + case dict.get(results, uri_str) { 298 + Ok(labels) -> Ok(value.List(labels)) 299 + Error(_) -> Ok(value.List([])) 300 + } 301 + } 302 + Error(_) -> Ok(value.List([])) 303 + } 304 + } 305 + Error(_) -> Ok(value.List([])) 306 + } 307 + }, 308 + ``` 309 + 310 + **Step 4: Verify it compiles** 311 + 312 + Run: `cd /Users/chadmiller/code/quickslice && gleam build` 313 + Expected: Compiles without errors 314 + 315 + **Step 5: Commit** 316 + 317 + ```bash 318 + git add -A 319 + git commit -m "feat: merge self-labels from record JSON with moderator labels" 320 + ``` 321 + 322 + --- 323 + 324 + ### Task 5: Handle Field Collision 325 + 326 + **Files:** 327 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 328 + 329 + **Step 1: Skip adding labels field if lexicon defines it** 330 + 331 + Update `build_labels_field` to check for collision: 332 + 333 + ```gleam 334 + fn build_labels_field( 335 + labels_fetcher: option.Option(LabelsFetcher), 336 + properties: List(#(String, types.Property)), 337 + ) -> List(schema.Field) { 338 + case labels_fetcher { 339 + option.None -> [] 340 + option.Some(fetcher) -> { 341 + // Skip if lexicon already defines a labels field 342 + case has_self_labels_property(properties) { 343 + True -> [] 344 + False -> { 345 + let label_type = build_label_type() 346 + [ 347 + schema.field( 348 + "labels", 349 + schema.non_null(schema.list_type(schema.non_null(label_type))), 350 + "Labels applied to this record", 351 + fn(ctx) { 352 + case get_field_from_context(ctx, "uri") { 353 + Ok(uri_str) -> { 354 + let json_opt = case get_field_from_context(ctx, "json") { 355 + Ok(j) -> option.Some(j) 356 + Error(_) -> option.None 357 + } 358 + case fetcher([#(uri_str, json_opt)]) { 359 + Ok(results) -> { 360 + case dict.get(results, uri_str) { 361 + Ok(labels) -> Ok(value.List(labels)) 362 + Error(_) -> Ok(value.List([])) 363 + } 364 + } 365 + Error(_) -> Ok(value.List([])) 366 + } 367 + } 368 + Error(_) -> Ok(value.List([])) 369 + } 370 + }, 371 + ), 372 + ] 373 + } 374 + } 375 + } 376 + } 377 + } 378 + ``` 379 + 380 + **Step 2: Verify it compiles** 381 + 382 + Run: `cd /Users/chadmiller/code/quickslice && gleam build` 383 + Expected: Compiles without errors 384 + 385 + **Step 3: Commit** 386 + 387 + ```bash 388 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 389 + git commit -m "fix(lexicon_graphql): skip labels field if lexicon defines selfLabels" 390 + ``` 391 + 392 + --- 393 + 394 + ### Task 6: Override Lexicon's Labels Field Resolver 395 + 396 + When the lexicon defines a `labels` property, we need to replace its resolver with our enhanced version that merges self-labels with moderator labels. 397 + 398 + **Files:** 399 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 400 + 401 + **Step 1: Add function to replace labels field resolver** 402 + 403 + Add after `has_self_labels_property`: 404 + 405 + ```gleam 406 + /// Replace the labels field resolver with an enhanced version that merges moderator labels 407 + fn replace_labels_field_resolver( 408 + fields: List(schema.Field), 409 + labels_fetcher: option.Option(LabelsFetcher), 410 + ) -> List(schema.Field) { 411 + case labels_fetcher { 412 + option.None -> fields 413 + option.Some(fetcher) -> { 414 + list.map(fields, fn(field) { 415 + case schema.field_name(field) == "labels" { 416 + False -> field 417 + True -> { 418 + // Replace with enhanced resolver 419 + schema.field( 420 + "labels", 421 + schema.field_type(field), 422 + schema.field_description(field), 423 + fn(ctx) { 424 + case get_field_from_context(ctx, "uri") { 425 + Ok(uri_str) -> { 426 + let json_opt = case get_field_from_context(ctx, "json") { 427 + Ok(j) -> option.Some(j) 428 + Error(_) -> option.None 429 + } 430 + case fetcher([#(uri_str, json_opt)]) { 431 + Ok(results) -> { 432 + case dict.get(results, uri_str) { 433 + Ok(labels) -> Ok(value.List(labels)) 434 + Error(_) -> Ok(value.List([])) 435 + } 436 + } 437 + Error(_) -> Ok(value.List([])) 438 + } 439 + } 440 + Error(_) -> Ok(value.List([])) 441 + } 442 + }, 443 + ) 444 + } 445 + } 446 + }) 447 + } 448 + } 449 + } 450 + ``` 451 + 452 + **Step 2: Add accessor functions to schema module if missing** 453 + 454 + Check if `schema.field_name`, `schema.field_type`, `schema.field_description` exist. If not, we may need to add them or use pattern matching on the Field type. 455 + 456 + **Step 3: Apply the replacement in field building** 457 + 458 + Update around line 652 where fields are combined: 459 + 460 + ```gleam 461 + // Replace lexicon's labels field resolver if it exists 462 + let enhanced_fields = case has_self_labels_property(record_type.properties) { 463 + True -> replace_labels_field_resolver(record_type.fields, labels_fetcher) 464 + False -> record_type.fields 465 + } 466 + 467 + // Build labels field if labels_fetcher is provided 468 + let labels_field = build_labels_field(labels_fetcher, record_type.properties) 469 + 470 + // Combine all fields 471 + let all_fields = 472 + list.flatten([ 473 + enhanced_fields, // Use enhanced instead of record_type.fields 474 + forward_join_fields, 475 + reverse_join_fields, 476 + did_join_fields, 477 + viewer_fields, 478 + did_viewer_fields, 479 + labels_field, 480 + ]) 481 + ``` 482 + 483 + **Step 4: Verify it compiles** 484 + 485 + Run: `cd /Users/chadmiller/code/quickslice && gleam build` 486 + Expected: Compiles without errors 487 + 488 + **Step 5: Commit** 489 + 490 + ```bash 491 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 492 + git commit -m "feat(lexicon_graphql): enhance lexicon labels field with moderator labels" 493 + ``` 494 + 495 + --- 496 + 497 + ### Task 7: Write Integration Test 498 + 499 + **Files:** 500 + - Modify: `server/test/labels_test.gleam` 501 + 502 + **Step 1: Add test for self-labels parsing** 503 + 504 + ```gleam 505 + pub fn self_labels_parsing_test() { 506 + let assert Ok(db) = test_helpers.create_test_db() 507 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 508 + 509 + let uri = "at://did:plc:user1/app.bsky.feed.post/abc123" 510 + let json_with_self_labels = 511 + "{\"text\": \"hello\", \"labels\": {\"$type\": \"com.atproto.label.defs#selfLabels\", \"values\": [{\"val\": \"porn\"}]}}" 512 + 513 + // Create labels fetcher 514 + let fetcher = fetchers.labels_fetcher(db) 515 + 516 + // Call fetcher with JSON 517 + let assert Ok(results) = fetcher([#(uri, option.Some(json_with_self_labels))]) 518 + 519 + // Should have self-label 520 + let assert Ok(labels) = dict.get(results, uri) 521 + labels |> list.length() |> should.equal(1) 522 + } 523 + ``` 524 + 525 + **Step 2: Add test for merged labels** 526 + 527 + ```gleam 528 + pub fn merged_labels_test() { 529 + let assert Ok(db) = test_helpers.create_test_db() 530 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 531 + 532 + let uri = "at://did:plc:user1/app.bsky.feed.post/abc123" 533 + let json_with_self_labels = 534 + "{\"text\": \"hello\", \"labels\": {\"$type\": \"com.atproto.label.defs#selfLabels\", \"values\": [{\"val\": \"porn\"}]}}" 535 + 536 + // Add moderator label 537 + let assert Ok(_) = 538 + labels.insert(db, "did:plc:admin", uri, option.None, "spam", option.None) 539 + 540 + // Create labels fetcher 541 + let fetcher = fetchers.labels_fetcher(db) 542 + 543 + // Call fetcher with JSON 544 + let assert Ok(results) = fetcher([#(uri, option.Some(json_with_self_labels))]) 545 + 546 + // Should have both labels (1 self + 1 moderator) 547 + let assert Ok(labels_list) = dict.get(results, uri) 548 + labels_list |> list.length() |> should.equal(2) 549 + } 550 + ``` 551 + 552 + **Step 3: Run tests** 553 + 554 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 555 + Expected: All tests pass 556 + 557 + **Step 4: Commit** 558 + 559 + ```bash 560 + git add server/test/labels_test.gleam 561 + git commit -m "test: add self-labels parsing and merging tests" 562 + ``` 563 + 564 + --- 565 + 566 + ### Task 8: Update Documentation 567 + 568 + **Files:** 569 + - Modify: `docs/guides/moderation.md` 570 + 571 + **Step 1: Add section about self-labels** 572 + 573 + Add after the "Querying Labels" section: 574 + 575 + ```markdown 576 + ### Self-Labels 577 + 578 + Authors can label their own content by including a `labels` field in their record with type `com.atproto.label.defs#selfLabels`. Quickslice automatically merges self-labels with moderator labels. 579 + 580 + Example record with self-labels: 581 + 582 + ```json 583 + { 584 + "text": "Adult content warning", 585 + "labels": { 586 + "$type": "com.atproto.label.defs#selfLabels", 587 + "values": [{"val": "porn"}] 588 + } 589 + } 590 + ``` 591 + 592 + When querying, both self-labels and moderator labels appear in the `labels` field: 593 + 594 + ```graphql 595 + query { 596 + xyzPosts(first: 10) { 597 + nodes { 598 + uri 599 + labels { 600 + val 601 + src 602 + } 603 + } 604 + } 605 + } 606 + ``` 607 + 608 + Self-labels have the record author as the `src`. Moderator labels have the moderator's DID. 609 + ``` 610 + 611 + **Step 2: Commit** 612 + 613 + ```bash 614 + git add docs/guides/moderation.md 615 + git commit -m "docs: add self-labels documentation" 616 + ``` 617 + 618 + --- 619 + 620 + ### Task 9: Final Verification 621 + 622 + **Step 1: Run full test suite** 623 + 624 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 625 + Expected: All tests pass 626 + 627 + **Step 2: Run lexicon_graphql tests** 628 + 629 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 630 + Expected: All tests pass 631 + 632 + **Step 3: Manual testing** 633 + 634 + 1. Start the server 635 + 2. Query a record type that has self-labels in the lexicon 636 + 3. Verify the `labels` field returns merged labels 637 + 638 + --- 639 + 640 + ## Summary 641 + 642 + After completing these tasks: 643 + 1. Self-labels from record JSON are parsed and merged with moderator labels 644 + 2. No field collision when lexicons define their own `labels` property 645 + 3. Users get a unified view of all labels via the `labels` field
+604
dev-docs/plans/2025-12-30-admin-connection-pagination.md
··· 1 + # Admin Connection Pagination Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Convert `labels` and `reports` admin queries from simple lists to Relay-compliant Connection types with opaque cursors, totalCount, and proper PageInfo. 6 + 7 + **Architecture:** Add cursor encoding utilities, update repository functions to support cursor-based pagination with count queries, create Connection/Edge types using swell's helpers, and update resolvers to return connection values. 8 + 9 + **Tech Stack:** Gleam, swell/connection, base64 encoding for cursors, SQLite/Postgres repositories 10 + 11 + --- 12 + 13 + ### Task 1: Create Cursor Encoding Module 14 + 15 + **Files:** 16 + - Create: `server/src/graphql/admin/cursor.gleam` 17 + - Test: `server/test/graphql/admin/cursor_test.gleam` 18 + 19 + **Step 1: Write the failing test for encode** 20 + 21 + ```gleam 22 + // server/test/graphql/admin/cursor_test.gleam 23 + import gleeunit/should 24 + import graphql/admin/cursor 25 + 26 + pub fn encode_cursor_test() { 27 + cursor.encode("Label", 42) 28 + |> should.equal("TGFiZWw6NDI=") 29 + } 30 + 31 + pub fn encode_cursor_with_large_id_test() { 32 + cursor.encode("Report", 12345) 33 + |> should.equal("UmVwb3J0OjEyMzQ1") 34 + } 35 + ``` 36 + 37 + **Step 2: Run test to verify it fails** 38 + 39 + Run: `cd server && gleam test -- --only cursor_test` 40 + Expected: FAIL with module not found 41 + 42 + **Step 3: Write minimal implementation for encode** 43 + 44 + ```gleam 45 + // server/src/graphql/admin/cursor.gleam 46 + /// Cursor encoding/decoding for admin GraphQL connections 47 + /// 48 + /// Cursors are opaque base64-encoded strings in format "Type:ID" 49 + /// Example: "Label:42" -> "TGFiZWw6NDI=" 50 + import gleam/bit_array 51 + import gleam/int 52 + import gleam/result 53 + import gleam/string 54 + 55 + /// Encode a cursor from prefix and ID 56 + /// "Label", 42 -> "TGFiZWw6NDI=" 57 + pub fn encode(prefix: String, id: Int) -> String { 58 + let raw = prefix <> ":" <> int.to_string(id) 59 + bit_array.from_string(raw) 60 + |> bit_array.base64_encode(True) 61 + } 62 + ``` 63 + 64 + **Step 4: Run test to verify encode passes** 65 + 66 + Run: `cd server && gleam test -- --only cursor_test` 67 + Expected: PASS 68 + 69 + **Step 5: Write the failing test for decode** 70 + 71 + Add to `cursor_test.gleam`: 72 + 73 + ```gleam 74 + pub fn decode_cursor_test() { 75 + cursor.decode("TGFiZWw6NDI=") 76 + |> should.equal(Ok(#("Label", 42))) 77 + } 78 + 79 + pub fn decode_cursor_with_large_id_test() { 80 + cursor.decode("UmVwb3J0OjEyMzQ1") 81 + |> should.equal(Ok(#("Report", 12345))) 82 + } 83 + 84 + pub fn decode_invalid_cursor_test() { 85 + cursor.decode("not-valid-base64!!!") 86 + |> should.be_error() 87 + } 88 + 89 + pub fn decode_malformed_cursor_test() { 90 + // Valid base64 but wrong format (no colon) 91 + cursor.decode("bm9jb2xvbg==") 92 + |> should.be_error() 93 + } 94 + ``` 95 + 96 + **Step 6: Run test to verify decode fails** 97 + 98 + Run: `cd server && gleam test -- --only cursor_test` 99 + Expected: FAIL with function not defined 100 + 101 + **Step 7: Write minimal implementation for decode** 102 + 103 + Add to `cursor.gleam`: 104 + 105 + ```gleam 106 + /// Decode a cursor to prefix and ID 107 + /// "TGFiZWw6NDI=" -> Ok(#("Label", 42)) 108 + pub fn decode(cursor: String) -> Result(#(String, Int), Nil) { 109 + use decoded <- result.try( 110 + bit_array.base64_decode(cursor) 111 + |> result.then(fn(bits) { 112 + bit_array.to_string(bits) 113 + }) 114 + |> result.nil_error() 115 + ) 116 + 117 + case string.split(decoded, ":") { 118 + [prefix, id_str] -> { 119 + case int.parse(id_str) { 120 + Ok(id) -> Ok(#(prefix, id)) 121 + Error(_) -> Error(Nil) 122 + } 123 + } 124 + _ -> Error(Nil) 125 + } 126 + } 127 + ``` 128 + 129 + **Step 8: Run test to verify all pass** 130 + 131 + Run: `cd server && gleam test -- --only cursor_test` 132 + Expected: All PASS 133 + 134 + **Step 9: Commit** 135 + 136 + ```bash 137 + git add server/src/graphql/admin/cursor.gleam server/test/graphql/admin/cursor_test.gleam 138 + git commit -m "feat(admin): add cursor encoding/decoding for connection pagination" 139 + ``` 140 + 141 + --- 142 + 143 + ### Task 2: Add Connection Types to Admin Types 144 + 145 + **Files:** 146 + - Modify: `server/src/graphql/admin/types.gleam` 147 + 148 + **Step 1: Read the current types file to understand structure** 149 + 150 + Read: `server/src/graphql/admin/types.gleam` 151 + 152 + **Step 2: Add imports for swell/connection** 153 + 154 + Add to imports section: 155 + 156 + ```gleam 157 + import swell/connection 158 + ``` 159 + 160 + **Step 3: Add LabelEdge and LabelConnection types** 161 + 162 + Add after `label_type`: 163 + 164 + ```gleam 165 + /// Edge type for Label connection 166 + pub fn label_edge_type() -> schema.Type { 167 + connection.edge_type("Label", label_type()) 168 + } 169 + 170 + /// Connection type for paginated Label results 171 + pub fn label_connection_type() -> schema.Type { 172 + connection.connection_type("Label", label_edge_type()) 173 + } 174 + ``` 175 + 176 + **Step 4: Add ReportEdge and ReportConnection types** 177 + 178 + Add after `report_type`: 179 + 180 + ```gleam 181 + /// Edge type for Report connection 182 + pub fn report_edge_type() -> schema.Type { 183 + connection.edge_type("Report", report_type()) 184 + } 185 + 186 + /// Connection type for paginated Report results 187 + pub fn report_connection_type() -> schema.Type { 188 + connection.connection_type("Report", report_edge_type()) 189 + } 190 + ``` 191 + 192 + **Step 5: Build to verify types compile** 193 + 194 + Run: `cd server && gleam build` 195 + Expected: Build succeeds 196 + 197 + **Step 6: Commit** 198 + 199 + ```bash 200 + git add server/src/graphql/admin/types.gleam 201 + git commit -m "feat(admin): add Label and Report connection types" 202 + ``` 203 + 204 + --- 205 + 206 + ### Task 3: Update Labels Repository for Connection Pagination 207 + 208 + **Files:** 209 + - Modify: `server/src/database/repositories/labels.gleam` 210 + - Test: `server/test/database/repositories/labels_test.gleam` (if exists, otherwise add inline verification) 211 + 212 + **Step 1: Read the current labels repository** 213 + 214 + Read: `server/src/database/repositories/labels.gleam` 215 + 216 + **Step 2: Create new function signature for connection pagination** 217 + 218 + The existing `get_all` returns `Result(List(Label), Error)`. Add a new function that returns pagination info: 219 + 220 + ```gleam 221 + /// Result type for paginated label queries 222 + pub type PaginatedLabels { 223 + PaginatedLabels( 224 + labels: List(Label), 225 + has_next_page: Bool, 226 + total_count: Int, 227 + ) 228 + } 229 + 230 + /// Get labels with connection-style pagination 231 + /// Returns labels, whether there's a next page, and total count 232 + pub fn get_paginated( 233 + conn: Executor, 234 + uri_filter: Option(String), 235 + val_filter: Option(String), 236 + first: Int, 237 + after_id: Option(Int), 238 + ) -> Result(PaginatedLabels, Error) { 239 + // Fetch first + 1 to detect hasNextPage 240 + let fetch_limit = first + 1 241 + 242 + // Build base query conditions 243 + let uri_condition = case uri_filter { 244 + Some(uri) -> " AND uri = '" <> uri <> "'" 245 + None -> "" 246 + } 247 + let val_condition = case val_filter { 248 + Some(val) -> " AND val = '" <> val <> "'" 249 + None -> "" 250 + } 251 + let cursor_condition = case after_id { 252 + Some(id) -> " AND id < " <> int.to_string(id) 253 + None -> "" 254 + } 255 + 256 + let where_clause = "WHERE 1=1" <> uri_condition <> val_condition <> cursor_condition 257 + 258 + // Main query 259 + let query = "SELECT id, src, uri, cid, val, neg, created_at, expires_at, signature 260 + FROM labels " <> where_clause <> " 261 + ORDER BY id DESC 262 + LIMIT " <> int.to_string(fetch_limit) 263 + 264 + // Count query (without cursor, with filters) 265 + let count_where = "WHERE 1=1" <> uri_condition <> val_condition 266 + let count_query = "SELECT COUNT(*) FROM labels " <> count_where 267 + 268 + // Execute both queries 269 + use labels_result <- result.try(execute_query(conn, query)) 270 + use count_result <- result.try(execute_count_query(conn, count_query)) 271 + 272 + // Determine if there's a next page 273 + let has_next = list.length(labels_result) > first 274 + let labels = case has_next { 275 + True -> list.take(labels_result, first) 276 + False -> labels_result 277 + } 278 + 279 + Ok(PaginatedLabels( 280 + labels: labels, 281 + has_next_page: has_next, 282 + total_count: count_result, 283 + )) 284 + } 285 + ``` 286 + 287 + Note: The actual implementation will need to match the existing query patterns in this repository. Read the file first to adapt. 288 + 289 + **Step 3: Build and verify** 290 + 291 + Run: `cd server && gleam build` 292 + Expected: Build succeeds 293 + 294 + **Step 4: Commit** 295 + 296 + ```bash 297 + git add server/src/database/repositories/labels.gleam 298 + git commit -m "feat(labels): add get_paginated for connection pagination" 299 + ``` 300 + 301 + --- 302 + 303 + ### Task 4: Update Reports Repository for Connection Pagination 304 + 305 + **Files:** 306 + - Modify: `server/src/database/repositories/reports.gleam` 307 + 308 + **Step 1: Read the current reports repository** 309 + 310 + Read: `server/src/database/repositories/reports.gleam` 311 + 312 + **Step 2: Add paginated query function following same pattern as labels** 313 + 314 + Add similar `PaginatedReports` type and `get_paginated` function, adapting to the reports table structure and existing query patterns. 315 + 316 + **Step 3: Build and verify** 317 + 318 + Run: `cd server && gleam build` 319 + Expected: Build succeeds 320 + 321 + **Step 4: Commit** 322 + 323 + ```bash 324 + git add server/src/database/repositories/reports.gleam 325 + git commit -m "feat(reports): add get_paginated for connection pagination" 326 + ``` 327 + 328 + --- 329 + 330 + ### Task 5: Update Labels Query Resolver 331 + 332 + **Files:** 333 + - Modify: `server/src/graphql/admin/queries.gleam` 334 + 335 + **Step 1: Read the current queries file** 336 + 337 + Read: `server/src/graphql/admin/queries.gleam` 338 + 339 + **Step 2: Add imports** 340 + 341 + Add to imports: 342 + 343 + ```gleam 344 + import graphql/admin/cursor 345 + import swell/connection 346 + ``` 347 + 348 + **Step 3: Update labels field to use connection type and new arguments** 349 + 350 + Replace the labels field definition: 351 + 352 + ```gleam 353 + // labels query (admin only) - Connection type 354 + schema.field_with_args( 355 + "labels", 356 + schema.non_null(admin_types.label_connection_type()), 357 + "Get labels with optional filters (admin only)", 358 + [ 359 + schema.argument( 360 + "uri", 361 + schema.string_type(), 362 + "Filter by subject URI", 363 + None, 364 + ), 365 + schema.argument( 366 + "val", 367 + schema.string_type(), 368 + "Filter by label value", 369 + None, 370 + ), 371 + schema.argument( 372 + "first", 373 + schema.int_type(), 374 + "Number of items to fetch (default 50)", 375 + None, 376 + ), 377 + schema.argument( 378 + "after", 379 + schema.string_type(), 380 + "Cursor for pagination", 381 + None, 382 + ), 383 + ], 384 + fn(ctx) { 385 + case session.get_current_session(req, conn, did_cache) { 386 + Ok(sess) -> { 387 + case config_repo.is_admin(conn, sess.did) { 388 + True -> { 389 + let uri_filter = case schema.get_argument(ctx, "uri") { 390 + Some(value.String(u)) -> Some(u) 391 + _ -> None 392 + } 393 + let val_filter = case schema.get_argument(ctx, "val") { 394 + Some(value.String(v)) -> Some(v) 395 + _ -> None 396 + } 397 + let first = case schema.get_argument(ctx, "first") { 398 + Some(value.Int(f)) -> f 399 + _ -> 50 400 + } 401 + let after_id = case schema.get_argument(ctx, "after") { 402 + Some(value.String(c)) -> { 403 + case cursor.decode(c) { 404 + Ok(#("Label", id)) -> Some(id) 405 + _ -> None 406 + } 407 + } 408 + _ -> None 409 + } 410 + 411 + case labels.get_paginated(conn, uri_filter, val_filter, first, after_id) { 412 + Ok(paginated) -> { 413 + // Build edges with cursors 414 + let edges = list.map(paginated.labels, fn(label) { 415 + let label_value = converters.label_to_value(label) 416 + let cursor_str = cursor.encode("Label", label.id) 417 + connection.edge_to_value(connection.Edge( 418 + node: label_value, 419 + cursor: cursor_str, 420 + )) 421 + }) 422 + 423 + // Build page info 424 + let start_cursor = case list.first(paginated.labels) { 425 + Ok(first_label) -> Some(cursor.encode("Label", first_label.id)) 426 + Error(_) -> None 427 + } 428 + let end_cursor = case list.last(paginated.labels) { 429 + Ok(last_label) -> Some(cursor.encode("Label", last_label.id)) 430 + Error(_) -> None 431 + } 432 + 433 + let page_info = connection.PageInfo( 434 + has_next_page: paginated.has_next_page, 435 + has_previous_page: option.is_some(after_id), 436 + start_cursor: start_cursor, 437 + end_cursor: end_cursor, 438 + ) 439 + 440 + let conn_value = connection.Connection( 441 + edges: list.map(paginated.labels, fn(label) { 442 + connection.Edge( 443 + node: converters.label_to_value(label), 444 + cursor: cursor.encode("Label", label.id), 445 + ) 446 + }), 447 + page_info: page_info, 448 + total_count: Some(paginated.total_count), 449 + ) 450 + 451 + Ok(connection.connection_to_value(conn_value)) 452 + } 453 + Error(_) -> Error("Failed to fetch labels") 454 + } 455 + } 456 + False -> Error("Admin privileges required") 457 + } 458 + } 459 + Error(_) -> Error("Authentication required") 460 + } 461 + }, 462 + ), 463 + ``` 464 + 465 + **Step 4: Build to verify** 466 + 467 + Run: `cd server && gleam build` 468 + Expected: Build succeeds 469 + 470 + **Step 5: Commit** 471 + 472 + ```bash 473 + git add server/src/graphql/admin/queries.gleam 474 + git commit -m "feat(admin): update labels query to return Connection type" 475 + ``` 476 + 477 + --- 478 + 479 + ### Task 6: Update Reports Query Resolver 480 + 481 + **Files:** 482 + - Modify: `server/src/graphql/admin/queries.gleam` 483 + 484 + **Step 1: Update reports field following same pattern as labels** 485 + 486 + Replace the reports field with connection-based version using `report_connection_type()`, `first`/`after` arguments, cursor encoding with "Report" prefix, and `reports.get_paginated`. 487 + 488 + **Step 2: Build to verify** 489 + 490 + Run: `cd server && gleam build` 491 + Expected: Build succeeds 492 + 493 + **Step 3: Commit** 494 + 495 + ```bash 496 + git add server/src/graphql/admin/queries.gleam 497 + git commit -m "feat(admin): update reports query to return Connection type" 498 + ``` 499 + 500 + --- 501 + 502 + ### Task 7: Integration Test 503 + 504 + **Files:** 505 + - Create: `server/test/graphql/admin/connection_test.gleam` 506 + 507 + **Step 1: Write integration test for labels connection** 508 + 509 + ```gleam 510 + // Test that labels query returns proper connection structure 511 + import gleeunit/should 512 + // ... setup code matching existing test patterns 513 + 514 + pub fn labels_connection_structure_test() { 515 + // Query should return edges, pageInfo, totalCount 516 + let query = " 517 + query { 518 + labels(first: 10) { 519 + edges { 520 + node { id val uri } 521 + cursor 522 + } 523 + pageInfo { 524 + hasNextPage 525 + hasPreviousPage 526 + startCursor 527 + endCursor 528 + } 529 + totalCount 530 + } 531 + } 532 + " 533 + // Execute and verify structure 534 + } 535 + 536 + pub fn labels_pagination_test() { 537 + // Test that after cursor works correctly 538 + } 539 + ``` 540 + 541 + **Step 2: Run tests** 542 + 543 + Run: `cd server && gleam test` 544 + Expected: All tests pass 545 + 546 + **Step 3: Commit** 547 + 548 + ```bash 549 + git add server/test/graphql/admin/connection_test.gleam 550 + git commit -m "test(admin): add integration tests for connection pagination" 551 + ``` 552 + 553 + --- 554 + 555 + ### Task 8: Final Verification and Cleanup 556 + 557 + **Step 1: Run full test suite** 558 + 559 + Run: `cd server && gleam test` 560 + Expected: All tests pass 561 + 562 + **Step 2: Manual verification via GraphQL** 563 + 564 + Start server and test at `/admin/graphql`: 565 + 566 + ```graphql 567 + query { 568 + labels(first: 5) { 569 + edges { 570 + node { id val uri createdAt } 571 + cursor 572 + } 573 + pageInfo { 574 + hasNextPage 575 + endCursor 576 + } 577 + totalCount 578 + } 579 + } 580 + ``` 581 + 582 + Then paginate: 583 + 584 + ```graphql 585 + query { 586 + labels(first: 5, after: "<endCursor from above>") { 587 + edges { 588 + node { id val } 589 + cursor 590 + } 591 + pageInfo { 592 + hasNextPage 593 + hasPreviousPage 594 + } 595 + } 596 + } 597 + ``` 598 + 599 + **Step 3: Commit any final fixes** 600 + 601 + ```bash 602 + git add -A 603 + git commit -m "chore: final cleanup for admin connection pagination" 604 + ```
+719
dev-docs/plans/2025-12-30-viewer-label-preferences.md
··· 1 + # Viewer Label Preferences Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Allow users to configure how they want each label type displayed (ignore, show, warn, hide). 6 + 7 + **Architecture:** Add `actor_label_preferences` table for per-user settings, extend `label_definitions` with `default_visibility`, expose via `viewerLabelPreferences` query and `setLabelPreference` mutation on the lexicon GraphQL API. System labels (starting with `!`) are excluded since the server enforces them. 8 + 9 + **Tech Stack:** Gleam, SQLite/Postgres, swell GraphQL library 10 + 11 + --- 12 + 13 + ### Task 1: Add Migration for Label Preferences 14 + 15 + **Files:** 16 + - Create: `server/priv/migrations/XXXXXX_add_label_preferences.sql` 17 + 18 + **Step 1: Create migration file** 19 + 20 + ```sql 21 + -- Add default_visibility to label_definitions 22 + ALTER TABLE label_definitions ADD COLUMN default_visibility TEXT NOT NULL DEFAULT 'warn'; 23 + 24 + -- Create actor_label_preferences table 25 + CREATE TABLE IF NOT EXISTS actor_label_preferences ( 26 + did TEXT NOT NULL, 27 + label_val TEXT NOT NULL, 28 + visibility TEXT NOT NULL, 29 + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), 30 + PRIMARY KEY (did, label_val) 31 + ); 32 + 33 + -- Index for fast lookups by user 34 + CREATE INDEX IF NOT EXISTS idx_actor_label_preferences_did ON actor_label_preferences(did); 35 + ``` 36 + 37 + **Step 2: Run migration** 38 + 39 + Run: `cd server && gleam run -m migrate` 40 + Expected: Migration applies successfully 41 + 42 + **Step 3: Commit** 43 + 44 + ```bash 45 + git add server/priv/migrations/ 46 + git commit -m "feat(db): add label preferences migration" 47 + ``` 48 + 49 + --- 50 + 51 + ### Task 2: Create Label Preferences Repository 52 + 53 + **Files:** 54 + - Create: `server/src/database/repositories/label_preferences.gleam` 55 + 56 + **Step 1: Create repository module** 57 + 58 + ```gleam 59 + /// Repository for actor label preferences 60 + import database/executor.{type Executor} 61 + import gleam/dynamic 62 + import gleam/list 63 + import gleam/option.{type Option, None, Some} 64 + import gleam/result 65 + 66 + /// A label preference record 67 + pub type LabelPreference { 68 + LabelPreference( 69 + did: String, 70 + label_val: String, 71 + visibility: String, 72 + created_at: String, 73 + ) 74 + } 75 + 76 + /// Get all preferences for a user 77 + pub fn get_by_did( 78 + conn: Executor, 79 + did: String, 80 + ) -> Result(List(LabelPreference), String) { 81 + let sql = 82 + "SELECT did, label_val, visibility, created_at 83 + FROM actor_label_preferences 84 + WHERE did = ?" 85 + 86 + case executor.query(conn, sql, [executor.string(did)], preference_decoder()) { 87 + Ok(rows) -> Ok(rows) 88 + Error(_) -> Error("Failed to fetch label preferences") 89 + } 90 + } 91 + 92 + /// Get a specific preference 93 + pub fn get( 94 + conn: Executor, 95 + did: String, 96 + label_val: String, 97 + ) -> Result(Option(LabelPreference), String) { 98 + let sql = 99 + "SELECT did, label_val, visibility, created_at 100 + FROM actor_label_preferences 101 + WHERE did = ? AND label_val = ?" 102 + 103 + case 104 + executor.query(conn, sql, [executor.string(did), executor.string(label_val)], preference_decoder()) 105 + { 106 + Ok([pref, ..]) -> Ok(Some(pref)) 107 + Ok([]) -> Ok(None) 108 + Error(_) -> Error("Failed to fetch label preference") 109 + } 110 + } 111 + 112 + /// Set a preference (upsert) 113 + pub fn set( 114 + conn: Executor, 115 + did: String, 116 + label_val: String, 117 + visibility: String, 118 + ) -> Result(LabelPreference, String) { 119 + let sql = 120 + "INSERT INTO actor_label_preferences (did, label_val, visibility) 121 + VALUES (?, ?, ?) 122 + ON CONFLICT(did, label_val) DO UPDATE SET visibility = excluded.visibility 123 + RETURNING did, label_val, visibility, created_at" 124 + 125 + case 126 + executor.query( 127 + conn, 128 + sql, 129 + [executor.string(did), executor.string(label_val), executor.string(visibility)], 130 + preference_decoder(), 131 + ) 132 + { 133 + Ok([pref, ..]) -> Ok(pref) 134 + Ok([]) -> Error("Failed to set preference") 135 + Error(_) -> Error("Failed to set label preference") 136 + } 137 + } 138 + 139 + /// Delete a preference (reset to default) 140 + pub fn delete( 141 + conn: Executor, 142 + did: String, 143 + label_val: String, 144 + ) -> Result(Nil, String) { 145 + let sql = 146 + "DELETE FROM actor_label_preferences WHERE did = ? AND label_val = ?" 147 + 148 + case executor.execute(conn, sql, [executor.string(did), executor.string(label_val)]) { 149 + Ok(_) -> Ok(Nil) 150 + Error(_) -> Error("Failed to delete label preference") 151 + } 152 + } 153 + 154 + fn preference_decoder() -> dynamic.Decoder(LabelPreference) { 155 + dynamic.decode4( 156 + LabelPreference, 157 + dynamic.element(0, dynamic.string), 158 + dynamic.element(1, dynamic.string), 159 + dynamic.element(2, dynamic.string), 160 + dynamic.element(3, dynamic.string), 161 + ) 162 + } 163 + ``` 164 + 165 + **Step 2: Build to verify** 166 + 167 + Run: `cd server && gleam build` 168 + Expected: Build succeeds 169 + 170 + **Step 3: Commit** 171 + 172 + ```bash 173 + git add server/src/database/repositories/label_preferences.gleam 174 + git commit -m "feat(db): add label preferences repository" 175 + ``` 176 + 177 + --- 178 + 179 + ### Task 3: Update Label Definitions Repository 180 + 181 + **Files:** 182 + - Modify: `server/src/database/repositories/label_definitions.gleam` 183 + 184 + **Step 1: Update LabelDefinition type to include default_visibility** 185 + 186 + Add `default_visibility: String` field to the `LabelDefinition` type and update all decoders and queries to include it. 187 + 188 + **Step 2: Update insert function signature** 189 + 190 + Add `default_visibility` parameter to the `insert` function: 191 + 192 + ```gleam 193 + pub fn insert( 194 + conn: Executor, 195 + val: String, 196 + description: String, 197 + severity: String, 198 + default_visibility: String, 199 + ) -> Result(Nil, String) 200 + ``` 201 + 202 + **Step 3: Add get_non_system function** 203 + 204 + ```gleam 205 + /// Get all non-system label definitions (excludes labels starting with !) 206 + pub fn get_non_system(conn: Executor) -> Result(List(LabelDefinition), String) { 207 + let sql = 208 + "SELECT val, description, severity, default_visibility, created_at 209 + FROM label_definitions 210 + WHERE val NOT LIKE '!%' 211 + ORDER BY val" 212 + 213 + case executor.query(conn, sql, [], definition_decoder()) { 214 + Ok(rows) -> Ok(rows) 215 + Error(_) -> Error("Failed to fetch label definitions") 216 + } 217 + } 218 + ``` 219 + 220 + **Step 4: Build to verify** 221 + 222 + Run: `cd server && gleam build` 223 + Expected: Build succeeds (may have errors in callers that need updating) 224 + 225 + **Step 5: Commit** 226 + 227 + ```bash 228 + git add server/src/database/repositories/label_definitions.gleam 229 + git commit -m "feat(db): add default_visibility to label definitions" 230 + ``` 231 + 232 + --- 233 + 234 + ### Task 4: Update createLabelDefinition Mutation 235 + 236 + **Files:** 237 + - Modify: `server/src/graphql/admin/mutations.gleam` 238 + 239 + **Step 1: Add defaultVisibility argument** 240 + 241 + Add new argument to `createLabelDefinition` mutation: 242 + 243 + ```gleam 244 + schema.argument( 245 + "defaultVisibility", 246 + schema.string_type(), 247 + "Default visibility setting (ignore, show, warn, hide). Defaults to warn.", 248 + None, 249 + ), 250 + ``` 251 + 252 + **Step 2: Extract and validate defaultVisibility** 253 + 254 + ```gleam 255 + let default_visibility = case 256 + schema.get_argument(ctx, "defaultVisibility") 257 + { 258 + Some(value.Enum(v)) -> string.lowercase(v) 259 + Some(value.String(v)) -> string.lowercase(v) 260 + _ -> "warn" 261 + } 262 + 263 + // Validate 264 + let valid_visibilities = ["ignore", "show", "warn", "hide"] 265 + use _ <- result.try(case list.contains(valid_visibilities, default_visibility) { 266 + True -> Ok(Nil) 267 + False -> Error("defaultVisibility must be one of: ignore, show, warn, hide") 268 + }) 269 + ``` 270 + 271 + **Step 3: Pass to insert** 272 + 273 + Update the `label_definitions.insert` call to include `default_visibility`. 274 + 275 + **Step 4: Build to verify** 276 + 277 + Run: `cd server && gleam build` 278 + Expected: Build succeeds 279 + 280 + **Step 5: Commit** 281 + 282 + ```bash 283 + git add server/src/graphql/admin/mutations.gleam 284 + git commit -m "feat(admin): add defaultVisibility to createLabelDefinition" 285 + ``` 286 + 287 + --- 288 + 289 + ### Task 5: Add Visibility Enum to Lexicon Types 290 + 291 + **Files:** 292 + - Modify: `server/src/graphql/lexicon/types.gleam` 293 + 294 + **Step 1: Add LabelVisibility enum** 295 + 296 + ```gleam 297 + /// LabelVisibility enum for user preferences 298 + pub fn label_visibility_enum() -> schema.Type { 299 + schema.enum_type("LabelVisibility", "How to display labeled content", [ 300 + schema.enum_value("IGNORE", "Show content normally, no indicator"), 301 + schema.enum_value("SHOW", "Explicitly show (for adult content)"), 302 + schema.enum_value("WARN", "Blur with click-through warning"), 303 + schema.enum_value("HIDE", "Do not show content"), 304 + ]) 305 + } 306 + ``` 307 + 308 + **Step 2: Add LabelPreference type** 309 + 310 + ```gleam 311 + /// LabelPreference type for viewerLabelPreferences query 312 + pub fn label_preference_type() -> schema.Type { 313 + schema.object_type("LabelPreference", "User preference for a label type", [ 314 + schema.field( 315 + "val", 316 + schema.non_null(schema.string_type()), 317 + "Label value", 318 + fn(ctx) { Ok(get_field(ctx, "val")) }, 319 + ), 320 + schema.field( 321 + "description", 322 + schema.non_null(schema.string_type()), 323 + "Label description", 324 + fn(ctx) { Ok(get_field(ctx, "description")) }, 325 + ), 326 + schema.field( 327 + "severity", 328 + schema.non_null(schema.string_type()), 329 + "Label severity (inform, alert, none)", 330 + fn(ctx) { Ok(get_field(ctx, "severity")) }, 331 + ), 332 + schema.field( 333 + "defaultVisibility", 334 + schema.non_null(label_visibility_enum()), 335 + "Default visibility setting", 336 + fn(ctx) { Ok(get_field(ctx, "defaultVisibility")) }, 337 + ), 338 + schema.field( 339 + "visibility", 340 + schema.non_null(label_visibility_enum()), 341 + "User's effective visibility setting", 342 + fn(ctx) { Ok(get_field(ctx, "visibility")) }, 343 + ), 344 + ]) 345 + } 346 + ``` 347 + 348 + **Step 3: Build to verify** 349 + 350 + Run: `cd server && gleam build` 351 + Expected: Build succeeds 352 + 353 + **Step 4: Commit** 354 + 355 + ```bash 356 + git add server/src/graphql/lexicon/types.gleam 357 + git commit -m "feat(lexicon): add LabelVisibility enum and LabelPreference type" 358 + ``` 359 + 360 + --- 361 + 362 + ### Task 6: Add viewerLabelPreferences Query 363 + 364 + **Files:** 365 + - Modify: `server/src/graphql/lexicon/queries.gleam` 366 + 367 + **Step 1: Add viewerLabelPreferences field** 368 + 369 + ```gleam 370 + schema.field( 371 + "viewerLabelPreferences", 372 + schema.non_null(schema.list_type(schema.non_null(types.label_preference_type()))), 373 + "Get label visibility preferences for the authenticated user", 374 + fn(_ctx) { 375 + // Check authentication 376 + case get_authenticated_session(ctx) { 377 + Error(e) -> Error(e) 378 + Ok(auth) -> { 379 + // Get all non-system label definitions 380 + case label_definitions.get_non_system(conn) { 381 + Error(e) -> Error(e) 382 + Ok(definitions) -> { 383 + // Get user's preferences 384 + case label_preferences.get_by_did(conn, auth.user_info.did) { 385 + Error(e) -> Error(e) 386 + Ok(prefs) -> { 387 + // Build preference map 388 + let pref_map = 389 + list.fold(prefs, dict.new(), fn(acc, p) { 390 + dict.insert(acc, p.label_val, p.visibility) 391 + }) 392 + 393 + // Merge definitions with user prefs 394 + let results = 395 + list.map(definitions, fn(def) { 396 + let visibility = case dict.get(pref_map, def.val) { 397 + Ok(v) -> v 398 + Error(_) -> def.default_visibility 399 + } 400 + value.Object([ 401 + #("val", value.String(def.val)), 402 + #("description", value.String(def.description)), 403 + #("severity", value.String(def.severity)), 404 + #("defaultVisibility", value.Enum(string.uppercase(def.default_visibility))), 405 + #("visibility", value.Enum(string.uppercase(visibility))), 406 + ]) 407 + }) 408 + 409 + Ok(value.List(results)) 410 + } 411 + } 412 + } 413 + } 414 + } 415 + } 416 + }, 417 + ), 418 + ``` 419 + 420 + **Step 2: Add required imports** 421 + 422 + Add imports for `label_definitions`, `label_preferences`, `dict`. 423 + 424 + **Step 3: Build to verify** 425 + 426 + Run: `cd server && gleam build` 427 + Expected: Build succeeds 428 + 429 + **Step 4: Commit** 430 + 431 + ```bash 432 + git add server/src/graphql/lexicon/queries.gleam 433 + git commit -m "feat(lexicon): add viewerLabelPreferences query" 434 + ``` 435 + 436 + --- 437 + 438 + ### Task 7: Add setLabelPreference Mutation 439 + 440 + **Files:** 441 + - Modify: `server/src/graphql/lexicon/mutations.gleam` 442 + 443 + **Step 1: Add setLabelPreference field** 444 + 445 + ```gleam 446 + schema.field_with_args( 447 + "setLabelPreference", 448 + schema.non_null(types.label_preference_type()), 449 + "Set visibility preference for a label type", 450 + [ 451 + schema.argument( 452 + "val", 453 + schema.non_null(schema.string_type()), 454 + "Label value", 455 + None, 456 + ), 457 + schema.argument( 458 + "visibility", 459 + schema.non_null(types.label_visibility_enum()), 460 + "Visibility setting", 461 + None, 462 + ), 463 + ], 464 + fn(ctx) { 465 + // Check authentication 466 + case get_authenticated_session(resolver_ctx, ctx) { 467 + Error(e) -> Error(e) 468 + Ok(auth) -> { 469 + // Get arguments 470 + case 471 + schema.get_argument(resolver_ctx, "val"), 472 + schema.get_argument(resolver_ctx, "visibility") 473 + { 474 + Some(value.String(val)), Some(value.Enum(visibility)) -> { 475 + // Validate not a system label 476 + case string.starts_with(val, "!") { 477 + True -> Error("Cannot set preference for system labels") 478 + False -> { 479 + // Validate label exists 480 + case label_definitions.get(ctx.db, val) { 481 + Ok(None) -> Error("Unknown label: " <> val) 482 + Error(_) -> Error("Failed to validate label") 483 + Ok(Some(def)) -> { 484 + let visibility_lower = string.lowercase(visibility) 485 + // Set the preference 486 + case 487 + label_preferences.set( 488 + ctx.db, 489 + auth.user_info.did, 490 + val, 491 + visibility_lower, 492 + ) 493 + { 494 + Error(e) -> Error(e) 495 + Ok(_) -> { 496 + Ok(value.Object([ 497 + #("val", value.String(def.val)), 498 + #("description", value.String(def.description)), 499 + #("severity", value.String(def.severity)), 500 + #("defaultVisibility", value.Enum(string.uppercase(def.default_visibility))), 501 + #("visibility", value.Enum(visibility)), 502 + ])) 503 + } 504 + } 505 + } 506 + } 507 + } 508 + } 509 + } 510 + _, _ -> Error("val and visibility are required") 511 + } 512 + } 513 + } 514 + }, 515 + ), 516 + ``` 517 + 518 + **Step 2: Add required imports** 519 + 520 + Add imports for `label_definitions`, `label_preferences`. 521 + 522 + **Step 3: Build to verify** 523 + 524 + Run: `cd server && gleam build` 525 + Expected: Build succeeds 526 + 527 + **Step 4: Commit** 528 + 529 + ```bash 530 + git add server/src/graphql/lexicon/mutations.gleam 531 + git commit -m "feat(lexicon): add setLabelPreference mutation" 532 + ``` 533 + 534 + --- 535 + 536 + ### Task 8: Update Seed Data 537 + 538 + **Files:** 539 + - Modify: `server/src/database/seed.gleam` (or wherever labels are seeded) 540 + 541 + **Step 1: Update seed to include default_visibility** 542 + 543 + Update each seeded label definition to include appropriate default visibility: 544 + 545 + | Label | Default Visibility | 546 + |-------|-------------------| 547 + | `porn` | `hide` | 548 + | `sexual` | `warn` | 549 + | `nudity` | `warn` | 550 + | `gore` | `warn` | 551 + | `nsfl` | `warn` | 552 + | `graphic-media` | `warn` | 553 + | `spam` | `warn` | 554 + | `impersonation` | `warn` | 555 + 556 + **Step 2: Build and test seed** 557 + 558 + Run: `cd server && gleam build` 559 + Expected: Build succeeds 560 + 561 + **Step 3: Commit** 562 + 563 + ```bash 564 + git add server/src/database/seed.gleam 565 + git commit -m "feat(db): add default_visibility to seeded labels" 566 + ``` 567 + 568 + --- 569 + 570 + ### Task 9: Add Integration Tests 571 + 572 + **Files:** 573 + - Create: `server/test/label_preferences_test.gleam` 574 + 575 + **Step 1: Write test for viewerLabelPreferences query** 576 + 577 + ```gleam 578 + pub fn viewer_label_preferences_returns_all_non_system_labels_test() { 579 + use conn <- test_helpers.with_test_db() 580 + use auth <- test_helpers.with_authenticated_user(conn) 581 + 582 + let query = " 583 + query { 584 + viewerLabelPreferences { 585 + val 586 + visibility 587 + defaultVisibility 588 + } 589 + } 590 + " 591 + 592 + let result = execute_query(conn, query, auth) 593 + 594 + // Should return labels, none starting with ! 595 + let prefs = get_field(result, "viewerLabelPreferences") 596 + assert list.length(prefs) > 0 597 + assert list.all(prefs, fn(p) { !string.starts_with(p.val, "!") }) 598 + } 599 + ``` 600 + 601 + **Step 2: Write test for setLabelPreference mutation** 602 + 603 + ```gleam 604 + pub fn set_label_preference_updates_visibility_test() { 605 + use conn <- test_helpers.with_test_db() 606 + use auth <- test_helpers.with_authenticated_user(conn) 607 + 608 + let mutation = " 609 + mutation { 610 + setLabelPreference(val: \"spam\", visibility: HIDE) { 611 + val 612 + visibility 613 + } 614 + } 615 + " 616 + 617 + let result = execute_mutation(conn, mutation, auth) 618 + 619 + assert result.val == "spam" 620 + assert result.visibility == "HIDE" 621 + } 622 + ``` 623 + 624 + **Step 3: Write test for system label rejection** 625 + 626 + ```gleam 627 + pub fn set_label_preference_rejects_system_labels_test() { 628 + use conn <- test_helpers.with_test_db() 629 + use auth <- test_helpers.with_authenticated_user(conn) 630 + 631 + let mutation = " 632 + mutation { 633 + setLabelPreference(val: \"!takedown\", visibility: IGNORE) { 634 + val 635 + } 636 + } 637 + " 638 + 639 + let result = execute_mutation(conn, mutation, auth) 640 + 641 + assert result.errors != [] 642 + assert string.contains(result.errors[0], "system labels") 643 + } 644 + ``` 645 + 646 + **Step 4: Run tests** 647 + 648 + Run: `cd server && gleam test` 649 + Expected: All tests pass 650 + 651 + **Step 5: Commit** 652 + 653 + ```bash 654 + git add server/test/label_preferences_test.gleam 655 + git commit -m "test(lexicon): add label preferences integration tests" 656 + ``` 657 + 658 + --- 659 + 660 + ### Task 10: Update Documentation 661 + 662 + **Files:** 663 + - Modify: `docs/guides/moderation.md` 664 + 665 + **Step 1: Add Label Preferences section** 666 + 667 + Add after "Admin Access" section: 668 + 669 + ```markdown 670 + ## Label Preferences 671 + 672 + Users can configure how labeled content appears to them. 673 + 674 + ### Visibility Settings 675 + 676 + | Setting | Behavior | 677 + |---------|----------| 678 + | `IGNORE` | Show content normally, no indicator | 679 + | `SHOW` | Explicitly show (for adult content) | 680 + | `WARN` | Blur with "Show anyway" option | 681 + | `HIDE` | Do not display content | 682 + 683 + ### Querying Preferences 684 + 685 + Authenticated users fetch their preferences: 686 + 687 + ```graphql 688 + query { 689 + viewerLabelPreferences { 690 + val 691 + description 692 + visibility 693 + defaultVisibility 694 + } 695 + } 696 + ``` 697 + 698 + ### Setting Preferences 699 + 700 + Update a preference: 701 + 702 + ```graphql 703 + mutation { 704 + setLabelPreference(val: "spam", visibility: HIDE) { 705 + val 706 + visibility 707 + } 708 + } 709 + ``` 710 + 711 + System labels (starting with `!`) cannot be configured—the server enforces them. 712 + ``` 713 + 714 + **Step 2: Commit** 715 + 716 + ```bash 717 + git add docs/guides/moderation.md 718 + git commit -m "docs(moderation): add label preferences documentation" 719 + ```
+303
docs/guides/moderation.md
··· 1 + # Moderation 2 + 3 + Quickslice provides AT Protocol-compatible moderation through labels and reports. Labels mark content; reports let users flag problems. 4 + 5 + > **Note:** Admin operations use the `/admin/graphql` endpoint. User operations like `createReport` use the main `/graphql` endpoint. 6 + 7 + ## Labels 8 + 9 + Labels attach metadata to records or accounts. Apply a `!takedown` label to hide content from queries. Apply `porn` or `gore` to trigger client-side warnings. 10 + 11 + ### Label Definitions 12 + 13 + Each instance defines which labels it accepts. Quickslice seeds common defaults: 14 + 15 + | Value | Severity | Effect | 16 + |-------|----------|--------| 17 + | `!takedown` | takedown | Hides from all queries | 18 + | `!suspend` | takedown | Hides from all queries | 19 + | `!warn` | alert | Clients show warning | 20 + | `!hide` | alert | Hidden from feeds | 21 + | `porn` | alert | Adult content warning | 22 + | `sexual` | alert | Suggestive content warning | 23 + | `nudity` | alert | Non-sexual nudity warning | 24 + | `gore` | alert | Graphic violence warning | 25 + | `graphic-media` | alert | Disturbing media warning | 26 + | `spam` | inform | Spam indicator | 27 + | `impersonation` | inform | Impersonation indicator | 28 + 29 + Create custom labels through the admin API. 30 + 31 + ### Applying Labels 32 + 33 + Admins apply labels via GraphQL: 34 + 35 + ```graphql 36 + mutation { 37 + createLabel( 38 + uri: "at://did:plc:xyz/app.bsky.feed.post/abc123" 39 + val: "!takedown" 40 + ) { 41 + id 42 + uri 43 + val 44 + cts 45 + } 46 + } 47 + ``` 48 + 49 + The `uri` identifies the target—a record URI or account DID. The `val` must match a defined label. 50 + 51 + ### Retracting Labels 52 + 53 + Labels persist until negated. To remove a label, create a negation: 54 + 55 + ```graphql 56 + mutation { 57 + negateLabel( 58 + uri: "at://did:plc:xyz/app.bsky.feed.post/abc123" 59 + val: "!takedown" 60 + ) { 61 + id 62 + neg 63 + } 64 + } 65 + ``` 66 + 67 + The negation cancels the original label. Content reappears in queries. 68 + 69 + ### Listing Labels (Admin) 70 + 71 + Admins can list all applied labels with optional filters: 72 + 73 + ```graphql 74 + query { 75 + labels(first: 20) { 76 + edges { 77 + node { 78 + id 79 + uri 80 + val 81 + src 82 + neg 83 + cts 84 + } 85 + } 86 + pageInfo { 87 + hasNextPage 88 + endCursor 89 + } 90 + } 91 + } 92 + ``` 93 + 94 + Filter by subject URI or label value: 95 + 96 + ```graphql 97 + query { 98 + labels(uri: "at://did:plc:xyz/app.bsky.feed.post/abc123", first: 10) { 99 + edges { 100 + node { 101 + val 102 + cts 103 + } 104 + } 105 + } 106 + } 107 + ``` 108 + 109 + ### Takedown Behavior 110 + 111 + Records with `!takedown` or `!suspend` labels disappear from all queries. Quickslice filters them automatically—clients never see hidden content. 112 + 113 + Pagination counts adjust for filtered records. A query for 10 items returns 10 visible items, not 10 minus takedowns. 114 + 115 + ### Querying Labels 116 + 117 + Every record type exposes a `labels` field: 118 + 119 + ```graphql 120 + query { 121 + xyzStatusphereStatuses(first: 10) { 122 + nodes { 123 + uri 124 + status 125 + labels { 126 + val 127 + src 128 + cts 129 + } 130 + } 131 + } 132 + } 133 + ``` 134 + 135 + Only active labels appear. Negated and expired labels are excluded. 136 + 137 + ### Self-Labels 138 + 139 + Authors can label their own content by including a `labels` field in their record with type `com.atproto.label.defs#selfLabels`. Quickslice automatically merges self-labels with moderator labels. 140 + 141 + Example record with self-labels: 142 + 143 + ```json 144 + { 145 + "text": "Adult content warning", 146 + "labels": { 147 + "$type": "com.atproto.label.defs#selfLabels", 148 + "values": [{"val": "porn"}] 149 + } 150 + } 151 + ``` 152 + 153 + When querying, both self-labels and moderator labels appear in the `labels` field: 154 + 155 + ```graphql 156 + query { 157 + xyzPosts(first: 10) { 158 + nodes { 159 + uri 160 + labels { 161 + val 162 + src 163 + } 164 + } 165 + } 166 + } 167 + ``` 168 + 169 + Self-labels have the record author's DID as the `src`. Moderator labels have the moderator's DID. 170 + 171 + ## Reports 172 + 173 + Reports let users flag content for moderator review. 174 + 175 + ### Creating Reports 176 + 177 + Authenticated users submit reports: 178 + 179 + ```graphql 180 + mutation { 181 + createReport( 182 + subjectUri: "at://did:plc:xyz/app.bsky.feed.post/abc123" 183 + reasonType: SPAM 184 + reason: "Promoting scam links" 185 + ) { 186 + id 187 + status 188 + createdAt 189 + } 190 + } 191 + ``` 192 + 193 + Valid reason types: `SPAM`, `VIOLATION`, `MISLEADING`, `SEXUAL`, `RUDE`, `OTHER`. 194 + 195 + Each user can report a URI once. Duplicate reports return the existing report. 196 + 197 + ### Reviewing Reports 198 + 199 + Admins list pending reports: 200 + 201 + ```graphql 202 + query { 203 + reports(status: PENDING, first: 20) { 204 + edges { 205 + node { 206 + id 207 + subjectUri 208 + reasonType 209 + reason 210 + reporterDid 211 + createdAt 212 + } 213 + } 214 + } 215 + } 216 + ``` 217 + 218 + ### Resolving Reports 219 + 220 + Resolve a report by applying a label or dismissing it: 221 + 222 + ```graphql 223 + mutation { 224 + resolveReport(id: 42, action: APPLY_LABEL, labelVal: "spam") { 225 + id 226 + status 227 + resolvedBy 228 + resolvedAt 229 + } 230 + } 231 + ``` 232 + 233 + Actions: 234 + - `APPLY_LABEL`: Creates a label on the reported content, marks report resolved 235 + - `DISMISS`: Marks report dismissed without action 236 + 237 + ## Admin Access 238 + 239 + Label and report management requires admin privileges. Configure admins by DID in your instance settings. 240 + 241 + Non-admins can: 242 + - View labels on records (via the `labels` field) 243 + - Submit reports 244 + - Configure their own label preferences 245 + 246 + Non-admins cannot: 247 + - Create or negate labels 248 + - View or resolve reports 249 + 250 + ## Label Preferences 251 + 252 + Users can configure how labeled content appears to them. This is exposed through the public `/graphql` endpoint, not the admin endpoint. 253 + 254 + ### Visibility Settings 255 + 256 + | Setting | Behavior | 257 + |---------|----------| 258 + | `IGNORE` | Show content normally, no indicator | 259 + | `SHOW` | Explicitly show (for adult content) | 260 + | `WARN` | Blur with "Show anyway" option | 261 + | `HIDE` | Do not display content | 262 + 263 + ### Querying Preferences 264 + 265 + Authenticated users fetch their preferences: 266 + 267 + ```graphql 268 + query { 269 + viewerLabelPreferences { 270 + val 271 + description 272 + severity 273 + visibility 274 + defaultVisibility 275 + } 276 + } 277 + ``` 278 + 279 + This returns all non-system labels with their current visibility settings. If the user has not set a preference, `visibility` equals `defaultVisibility`. 280 + 281 + ### Setting Preferences 282 + 283 + Update a preference: 284 + 285 + ```graphql 286 + mutation { 287 + setLabelPreference(val: "spam", visibility: HIDE) { 288 + val 289 + visibility 290 + } 291 + } 292 + ``` 293 + 294 + Reset to default by setting visibility to match `defaultVisibility`. 295 + 296 + ### System Labels 297 + 298 + System labels (starting with `!`) cannot be configured. The server enforces them: 299 + 300 + - `!takedown` and `!suspend` always hide content 301 + - `!warn` and `!hide` always apply their effects 302 + 303 + Attempting to set a preference for a system label returns an error.
+3 -1
docs/guides/queries.md
··· 1 1 # Queries 2 2 3 - Quickslice generates a GraphQL query for each Lexicon record type. Queries are public; no authentication required. 3 + Quickslice generates a GraphQL query for each Lexicon record type at the `/graphql` endpoint. Queries are public; no authentication required. 4 + 5 + > **Endpoints:** Lexicon queries and mutations use `/graphql`. Admin operations (labels, reports, settings) use `/admin/graphql`. 4 6 5 7 ## Relay Connections 6 8
+7
lexicon_graphql/src/lexicon_graphql.gleam
··· 18 18 import lexicon_graphql/schema/builder as schema_builder 19 19 import lexicon_graphql/schema/database as db_schema_builder 20 20 import lexicon_graphql/types 21 + import swell/schema 21 22 22 23 // Re-export core types 23 24 pub type Lexicon = ··· 63 64 viewer_fetcher: Option(ViewerFetcher), 64 65 notification_fetcher: Option(NotificationFetcher), 65 66 viewer_state_fetcher: Option(dataloader.ViewerStateFetcher), 67 + labels_fetcher: Option(db_schema_builder.LabelsFetcher), 68 + custom_mutation_fields: Option(List(schema.Field)), 69 + custom_query_fields: Option(List(schema.Field)), 66 70 ) { 67 71 db_schema_builder.build_schema_with_subscriptions( 68 72 lexicons, ··· 77 81 viewer_fetcher, 78 82 notification_fetcher, 79 83 viewer_state_fetcher, 84 + labels_fetcher, 85 + custom_mutation_fields, 86 + custom_query_fields, 80 87 ) 81 88 } 82 89
+151
lexicon_graphql/src/lexicon_graphql/input/union.gleam
··· 1 + /// Union Input Utilities 2 + /// 3 + /// Functions for handling AT Protocol union fields in GraphQL inputs. 4 + /// Provides case conversion between naming conventions and transformation 5 + /// from GraphQL discriminated union format to AT Protocol $type format. 6 + /// 7 + /// GraphQL input: { type: "SELF_LABELS", selfLabels: { values: [...] } } 8 + /// AT Protocol output: { $type: "com.atproto.label.defs#selfLabels", values: [...] } 9 + import gleam/list 10 + import gleam/string 11 + import swell/value 12 + 13 + // ─── Case Conversion ─────────────────────────────────────────────── 14 + 15 + /// Convert camelCase to SCREAMING_SNAKE_CASE 16 + /// "selfLabels" -> "SELF_LABELS" 17 + /// "myVariantType" -> "MY_VARIANT_TYPE" 18 + /// 19 + /// Note: Numbers are not treated as word boundaries. 20 + /// "oauth2Client" -> "OAUTH2_CLIENT" (not "OAUTH2CLIENT") 21 + pub fn camel_to_screaming_snake(s: String) -> String { 22 + s 23 + |> string.to_graphemes 24 + |> list.fold(#("", False), fn(acc, char) { 25 + let #(result, prev_was_lower) = acc 26 + let is_upper = is_uppercase_letter(char) 27 + case is_upper, prev_was_lower { 28 + True, True -> #(result <> "_" <> char, False) 29 + _, _ -> #(result <> string.uppercase(char), !is_upper) 30 + } 31 + }) 32 + |> fn(pair) { pair.0 } 33 + } 34 + 35 + /// Convert SCREAMING_SNAKE_CASE to camelCase 36 + /// "SELF_LABELS" -> "selfLabels" 37 + /// "MY_VARIANT_TYPE" -> "myVariantType" 38 + /// "OAUTH2_CLIENT" -> "oauth2Client" 39 + pub fn screaming_snake_to_camel(enum_value: String) -> String { 40 + let parts = string.split(enum_value, "_") 41 + case parts { 42 + [first, ..rest] -> { 43 + let lower_first = string.lowercase(first) 44 + let capitalized_rest = 45 + list.map(rest, fn(part) { 46 + case string.pop_grapheme(string.lowercase(part)) { 47 + Ok(#(first_char, remaining)) -> 48 + string.uppercase(first_char) <> remaining 49 + Error(_) -> part 50 + } 51 + }) 52 + lower_first <> string.concat(capitalized_rest) 53 + } 54 + [] -> enum_value 55 + } 56 + } 57 + 58 + // ─── Ref Utilities ───────────────────────────────────────────────── 59 + 60 + /// Extract short name from a fully-qualified ref 61 + /// "com.atproto.label.defs#selfLabels" -> "selfLabels" 62 + /// "com.atproto.label.defs" -> "defs" 63 + pub fn ref_to_short_name(ref: String) -> String { 64 + case string.split(ref, "#") { 65 + [_, name] -> name 66 + _ -> { 67 + case string.split(ref, ".") |> list.last { 68 + Ok(name) -> name 69 + Error(_) -> ref 70 + } 71 + } 72 + } 73 + } 74 + 75 + /// Convert a ref to SCREAMING_SNAKE_CASE enum value 76 + /// "com.atproto.label.defs#selfLabels" -> "SELF_LABELS" 77 + pub fn ref_to_enum_value(ref: String) -> String { 78 + ref_to_short_name(ref) |> camel_to_screaming_snake 79 + } 80 + 81 + // ─── Transformation ──────────────────────────────────────────────── 82 + 83 + /// Transform a union object from GraphQL discriminated format to AT Protocol format 84 + /// 85 + /// Takes fields from a GraphQL input object and a list of possible refs for this union. 86 + /// Returns the transformed value in AT Protocol format with $type field. 87 + pub fn transform_union_object( 88 + fields: List(#(String, value.Value)), 89 + refs: List(String), 90 + ) -> value.Value { 91 + // Find the "type" discriminator field 92 + let type_field = list.key_find(fields, "type") 93 + 94 + case type_field { 95 + Ok(value.Enum(enum_value)) -> { 96 + transform_with_enum_value(fields, refs, enum_value) 97 + } 98 + Ok(value.String(str_value)) -> { 99 + // Handle string type discriminator (fallback) 100 + transform_with_enum_value(fields, refs, str_value) 101 + } 102 + _ -> value.Object(fields) 103 + } 104 + } 105 + 106 + // ─── Internal Helpers ────────────────────────────────────────────── 107 + 108 + /// Check if a character is an uppercase letter (A-Z only) 109 + /// Returns False for numbers and other characters 110 + fn is_uppercase_letter(char: String) -> Bool { 111 + let upper = string.uppercase(char) 112 + let lower = string.lowercase(char) 113 + // Is uppercase AND is a letter (not number/symbol) 114 + upper == char && lower != char 115 + } 116 + 117 + /// Internal helper to transform using an enum value 118 + fn transform_with_enum_value( 119 + fields: List(#(String, value.Value)), 120 + refs: List(String), 121 + enum_value: String, 122 + ) -> value.Value { 123 + // Convert enum value back to ref 124 + let matching_ref = find_ref_for_enum_value(enum_value, refs) 125 + case matching_ref { 126 + Ok(ref) -> { 127 + // Find the variant field (same name as the short ref name) 128 + let short_name = screaming_snake_to_camel(enum_value) 129 + case list.key_find(fields, short_name) { 130 + Ok(value.Object(variant_fields)) -> { 131 + // Build AT Protocol format: variant fields + $type 132 + value.Object([#("$type", value.String(ref)), ..variant_fields]) 133 + } 134 + _ -> { 135 + // No variant data, just return $type 136 + value.Object([#("$type", value.String(ref))]) 137 + } 138 + } 139 + } 140 + Error(_) -> value.Object(fields) 141 + } 142 + } 143 + 144 + /// Find the ref that matches an enum value 145 + /// "SELF_LABELS" matches "com.atproto.label.defs#selfLabels" 146 + fn find_ref_for_enum_value( 147 + enum_value: String, 148 + refs: List(String), 149 + ) -> Result(String, Nil) { 150 + list.find(refs, fn(ref) { ref_to_enum_value(ref) == enum_value }) 151 + }
+212
lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam
··· 455 455 False -> ref 456 456 } 457 457 } 458 + 459 + /// Context needed for building union input types 460 + pub type UnionInputContext { 461 + UnionInputContext( 462 + input_types: Dict(String, schema.Type), 463 + parent_type_name: String, 464 + ) 465 + } 466 + 467 + /// Maps a lexicon property to a GraphQL input type, with union input registry lookup 468 + /// This version can resolve union refs to their generated input types 469 + /// For multi-variant unions, builds discriminated union input types on demand 470 + pub fn map_input_type_with_unions( 471 + property: types.Property, 472 + field_name: String, 473 + ctx: UnionInputContext, 474 + ) -> schema.Type { 475 + case property.type_ { 476 + // For unions, handle single vs multi-variant 477 + "union" -> { 478 + case property.refs { 479 + // Single-variant: use variant's input type directly 480 + option.Some([single_ref]) -> { 481 + case dict.get(ctx.input_types, single_ref) { 482 + Ok(input_type) -> input_type 483 + Error(_) -> schema.string_type() 484 + } 485 + } 486 + // Multi-variant: build discriminated union input (2 or more refs) 487 + option.Some([first, second, ..rest]) -> { 488 + let refs = [first, second, ..rest] 489 + build_multi_variant_union_input( 490 + ctx.parent_type_name, 491 + field_name, 492 + refs, 493 + ctx.input_types, 494 + ) 495 + } 496 + _ -> schema.string_type() 497 + } 498 + } 499 + 500 + // For refs, check the registry 501 + "ref" -> { 502 + case property.ref { 503 + option.Some(ref) -> { 504 + case dict.get(ctx.input_types, ref) { 505 + Ok(input_type) -> input_type 506 + Error(_) -> map_input_type("ref") 507 + } 508 + } 509 + option.None -> map_input_type("ref") 510 + } 511 + } 512 + 513 + // For arrays with ref/union items 514 + "array" -> { 515 + case property.items { 516 + option.Some(types.ArrayItems(item_type, item_ref, item_refs)) -> { 517 + case item_type { 518 + "ref" -> { 519 + case item_ref { 520 + option.Some(ref) -> { 521 + case dict.get(ctx.input_types, ref) { 522 + Ok(input_type) -> 523 + schema.list_type(schema.non_null(input_type)) 524 + Error(_) -> 525 + schema.list_type(schema.non_null(schema.string_type())) 526 + } 527 + } 528 + option.None -> 529 + schema.list_type(schema.non_null(schema.string_type())) 530 + } 531 + } 532 + "union" -> { 533 + case item_refs { 534 + // Single-variant array items 535 + option.Some([single_ref]) -> { 536 + case dict.get(ctx.input_types, single_ref) { 537 + Ok(input_type) -> 538 + schema.list_type(schema.non_null(input_type)) 539 + Error(_) -> 540 + schema.list_type(schema.non_null(schema.string_type())) 541 + } 542 + } 543 + // Multi-variant array items (2 or more refs) 544 + option.Some([first, second, ..rest]) -> { 545 + let refs = [first, second, ..rest] 546 + let item_union = 547 + build_multi_variant_union_input( 548 + ctx.parent_type_name, 549 + field_name <> "Item", 550 + refs, 551 + ctx.input_types, 552 + ) 553 + schema.list_type(schema.non_null(item_union)) 554 + } 555 + _ -> schema.list_type(schema.non_null(schema.string_type())) 556 + } 557 + } 558 + _ -> map_array_type(property.items, dict.new(), "", "") 559 + } 560 + } 561 + option.None -> schema.list_type(schema.non_null(schema.string_type())) 562 + } 563 + } 564 + 565 + // Default to regular input type mapping 566 + _ -> map_input_type(property.type_) 567 + } 568 + } 569 + 570 + /// Build a discriminated union input type for multi-variant unions 571 + fn build_multi_variant_union_input( 572 + parent_type_name: String, 573 + field_name: String, 574 + refs: List(String), 575 + existing_input_types: Dict(String, schema.Type), 576 + ) -> schema.Type { 577 + let union_name = parent_type_name <> capitalize_first(field_name) <> "Input" 578 + let enum_name = parent_type_name <> capitalize_first(field_name) <> "Type" 579 + 580 + // Build the type enum with variant names 581 + let enum_values = 582 + list.map(refs, fn(ref) { 583 + let value_name = ref_to_variant_enum_value(ref) 584 + schema.enum_value(value_name, "Select " <> ref <> " variant") 585 + }) 586 + 587 + let type_enum = 588 + schema.enum_type( 589 + enum_name, 590 + "Discriminator for " <> field_name <> " union variants", 591 + enum_values, 592 + ) 593 + 594 + // Build input fields: required type discriminator + optional variant fields 595 + let type_field = 596 + schema.input_field( 597 + "type", 598 + schema.non_null(type_enum), 599 + "Select which variant to use", 600 + option.None, 601 + ) 602 + 603 + let variant_fields = 604 + list.map(refs, fn(ref) { 605 + let variant_field_name = ref_to_variant_field_name(ref) 606 + let field_type = case dict.get(existing_input_types, ref) { 607 + Ok(input_type) -> input_type 608 + Error(_) -> schema.string_type() 609 + } 610 + schema.input_field( 611 + variant_field_name, 612 + field_type, 613 + // Optional - user provides data for selected variant 614 + "Data for " <> ref <> " variant", 615 + option.None, 616 + ) 617 + }) 618 + 619 + let all_fields = [type_field, ..variant_fields] 620 + 621 + schema.input_object_type( 622 + union_name, 623 + "Union input for " <> field_name <> " - use type field to select variant", 624 + all_fields, 625 + ) 626 + } 627 + 628 + /// Convert a ref to SCREAMING_SNAKE_CASE enum value 629 + fn ref_to_variant_enum_value(ref: String) -> String { 630 + let short_name = case string.split(ref, "#") { 631 + [_, name] -> name 632 + _ -> { 633 + case string.split(ref, ".") |> list.last { 634 + Ok(name) -> name 635 + Error(_) -> ref 636 + } 637 + } 638 + } 639 + camel_to_screaming_snake(short_name) 640 + } 641 + 642 + /// Convert camelCase to SCREAMING_SNAKE_CASE 643 + fn camel_to_screaming_snake(s: String) -> String { 644 + s 645 + |> string.to_graphemes 646 + |> list.fold(#("", False), fn(acc, char) { 647 + let #(result, prev_was_lower) = acc 648 + let is_upper = 649 + string.uppercase(char) == char && string.lowercase(char) != char 650 + case is_upper, prev_was_lower { 651 + True, True -> #(result <> "_" <> char, False) 652 + _, _ -> #(result <> string.uppercase(char), !is_upper) 653 + } 654 + }) 655 + |> fn(pair) { pair.0 } 656 + } 657 + 658 + /// Convert a ref to a camelCase field name 659 + fn ref_to_variant_field_name(ref: String) -> String { 660 + case string.split(ref, "#") { 661 + [_, name] -> name 662 + _ -> { 663 + case string.split(ref, ".") |> list.last { 664 + Ok(name) -> name 665 + Error(_) -> ref 666 + } 667 + } 668 + } 669 + }
+306
lexicon_graphql/src/lexicon_graphql/internal/graphql/union_input_builder.gleam
··· 1 + /// Union Input Builder 2 + /// 3 + /// Builds GraphQL input types for AT Protocol union fields. 4 + /// Generates input types from lexicon ObjectDefs, handling nested refs. 5 + /// 6 + /// For single-variant unions: returns the variant's input type directly 7 + /// For multi-variant unions: creates a discriminated input type with: 8 + /// - A `type` enum field for selecting the variant 9 + /// - Optional fields for each variant's data 10 + /// 11 + /// Also builds a UnionFieldRegistry for dynamic $type resolution at runtime. 12 + import gleam/dict.{type Dict} 13 + import gleam/list 14 + import gleam/option.{type Option} 15 + import gleam/string 16 + import lexicon_graphql/input/union as union_input 17 + import lexicon_graphql/internal/graphql/type_mapper 18 + import lexicon_graphql/internal/lexicon/nsid 19 + import lexicon_graphql/internal/lexicon/registry as lexicon_registry 20 + import lexicon_graphql/types 21 + import swell/schema 22 + 23 + /// Registry of generated union input types 24 + /// Maps fully-qualified ref (e.g., "com.atproto.label.defs#selfLabels") to input type 25 + pub type UnionInputRegistry = 26 + Dict(String, schema.Type) 27 + 28 + /// Mapping of union field paths to their variant refs 29 + /// Key: "collection.fieldName" (e.g., "social.grain.gallery.labels") 30 + /// Value: list of variant refs (e.g., ["com.atproto.label.defs#selfLabels"]) 31 + /// Used for dynamic $type resolution during mutation transformation 32 + pub type UnionFieldRegistry = 33 + Dict(String, List(String)) 34 + 35 + /// Combined registry for union inputs 36 + pub type UnionRegistry { 37 + UnionRegistry( 38 + input_types: UnionInputRegistry, 39 + field_variants: UnionFieldRegistry, 40 + ) 41 + } 42 + 43 + /// Build input types for all object defs in the registry 44 + /// Returns a registry with input types (field_variants populated separately) 45 + pub fn build_union_input_types( 46 + registry: lexicon_registry.Registry, 47 + ) -> UnionRegistry { 48 + let object_defs = lexicon_registry.get_all_object_defs(registry) 49 + 50 + // Build input types in dependency order (simple types first) 51 + // Do two passes: first build all without refs, then with refs 52 + let first_pass = 53 + dict.fold(object_defs, dict.new(), fn(acc, ref, obj_def) { 54 + let input_type = build_input_type_from_object_def(ref, obj_def, acc) 55 + dict.insert(acc, ref, input_type) 56 + }) 57 + 58 + // Second pass to resolve any remaining refs 59 + let input_types = 60 + dict.fold(object_defs, first_pass, fn(acc, ref, obj_def) { 61 + let input_type = build_input_type_from_object_def(ref, obj_def, acc) 62 + dict.insert(acc, ref, input_type) 63 + }) 64 + 65 + // field_variants is populated during build_input_type in mutation builder 66 + UnionRegistry(input_types: input_types, field_variants: dict.new()) 67 + } 68 + 69 + /// Add a union field entry to the registry 70 + pub fn register_union_field( 71 + registry: UnionRegistry, 72 + collection: String, 73 + field_name: String, 74 + refs: List(String), 75 + ) -> UnionRegistry { 76 + let key = collection <> "." <> field_name 77 + UnionRegistry( 78 + input_types: registry.input_types, 79 + field_variants: dict.insert(registry.field_variants, key, refs), 80 + ) 81 + } 82 + 83 + /// Look up union refs for a field 84 + pub fn get_union_refs( 85 + registry: UnionRegistry, 86 + collection: String, 87 + field_name: String, 88 + ) -> Option(List(String)) { 89 + let key = collection <> "." <> field_name 90 + dict.get(registry.field_variants, key) |> option.from_result 91 + } 92 + 93 + /// Convert a ref like "com.atproto.label.defs#selfLabels" to "SelfLabelsInput" 94 + pub fn ref_to_input_type_name(ref: String) -> String { 95 + let base_name = nsid.to_type_name(string.replace(ref, "#", ".")) 96 + base_name <> "Input" 97 + } 98 + 99 + /// Convert a ref to a short variant name for enum values 100 + /// "com.atproto.label.defs#selfLabels" -> "SELF_LABELS" 101 + pub fn ref_to_variant_enum_value(ref: String) -> String { 102 + union_input.ref_to_enum_value(ref) 103 + } 104 + 105 + /// Convert SCREAMING_SNAKE_CASE back to the original short name 106 + /// "SELF_LABELS" -> "selfLabels" 107 + pub fn enum_value_to_short_name(enum_value: String) -> String { 108 + union_input.screaming_snake_to_camel(enum_value) 109 + } 110 + 111 + /// Convert a ref to a camelCase field name for variant fields 112 + /// "com.atproto.label.defs#selfLabels" -> "selfLabels" 113 + pub fn ref_to_variant_field_name(ref: String) -> String { 114 + union_input.ref_to_short_name(ref) 115 + } 116 + 117 + /// Build a GraphQL input type from an ObjectDef 118 + fn build_input_type_from_object_def( 119 + ref: String, 120 + obj_def: types.ObjectDef, 121 + existing_input_types: UnionInputRegistry, 122 + ) -> schema.Type { 123 + let type_name = ref_to_input_type_name(ref) 124 + let required_fields = obj_def.required_fields 125 + 126 + let input_fields = 127 + list.map(obj_def.properties, fn(prop) { 128 + let #(name, property) = prop 129 + let is_required = list.contains(required_fields, name) 130 + 131 + // Get the input type for this property 132 + let field_type = 133 + map_property_to_input_type(property, existing_input_types) 134 + 135 + // Wrap in non_null if required 136 + let final_type = case is_required { 137 + True -> schema.non_null(field_type) 138 + False -> field_type 139 + } 140 + 141 + schema.input_field(name, final_type, "Input for " <> name, option.None) 142 + }) 143 + 144 + schema.input_object_type(type_name, "Input type for " <> ref, input_fields) 145 + } 146 + 147 + /// Build a discriminated union input type for multi-variant unions 148 + /// Creates an input with a `type` enum and optional fields for each variant 149 + pub fn build_multi_variant_union_input( 150 + parent_type_name: String, 151 + field_name: String, 152 + refs: List(String), 153 + existing_input_types: UnionInputRegistry, 154 + ) -> schema.Type { 155 + let union_name = parent_type_name <> capitalize_first(field_name) <> "Input" 156 + let enum_name = parent_type_name <> capitalize_first(field_name) <> "Type" 157 + 158 + // Build the type enum with variant names 159 + let enum_values = 160 + list.map(refs, fn(ref) { 161 + let value_name = ref_to_variant_enum_value(ref) 162 + schema.enum_value(value_name, "Select " <> ref <> " variant") 163 + }) 164 + 165 + let type_enum = 166 + schema.enum_type( 167 + enum_name, 168 + "Discriminator for " <> field_name <> " union variants", 169 + enum_values, 170 + ) 171 + 172 + // Build input fields: required type discriminator + optional variant fields 173 + let type_field = 174 + schema.input_field( 175 + "type", 176 + schema.non_null(type_enum), 177 + "Select which variant to use", 178 + option.None, 179 + ) 180 + 181 + let variant_fields = 182 + list.map(refs, fn(ref) { 183 + let variant_field_name = ref_to_variant_field_name(ref) 184 + let field_type = case dict.get(existing_input_types, ref) { 185 + Ok(input_type) -> input_type 186 + Error(_) -> schema.string_type() 187 + } 188 + schema.input_field( 189 + variant_field_name, 190 + field_type, 191 + // Optional - user provides data for selected variant 192 + "Data for " <> ref <> " variant", 193 + option.None, 194 + ) 195 + }) 196 + 197 + let all_fields = [type_field, ..variant_fields] 198 + 199 + schema.input_object_type( 200 + union_name, 201 + "Union input for " <> field_name <> " - use type to select variant", 202 + all_fields, 203 + ) 204 + } 205 + 206 + /// Map a lexicon property to a GraphQL input type 207 + fn map_property_to_input_type( 208 + property: types.Property, 209 + existing_input_types: UnionInputRegistry, 210 + ) -> schema.Type { 211 + case property.type_ { 212 + // For refs, check if we have a generated input type 213 + "ref" -> { 214 + case property.ref { 215 + option.Some(ref) -> { 216 + case dict.get(existing_input_types, ref) { 217 + Ok(input_type) -> input_type 218 + // Fall back to basic type mapping if no input type exists 219 + Error(_) -> type_mapper.map_input_type("ref") 220 + } 221 + } 222 + option.None -> type_mapper.map_input_type("ref") 223 + } 224 + } 225 + 226 + // For arrays, handle items 227 + "array" -> { 228 + case property.items { 229 + option.Some(types.ArrayItems(item_type, item_ref, _item_refs)) -> { 230 + let item_input_type = case item_type { 231 + "ref" -> { 232 + case item_ref { 233 + option.Some(ref) -> { 234 + case dict.get(existing_input_types, ref) { 235 + Ok(input_type) -> input_type 236 + Error(_) -> type_mapper.map_input_type("ref") 237 + } 238 + } 239 + option.None -> type_mapper.map_input_type("ref") 240 + } 241 + } 242 + _ -> type_mapper.map_input_type(item_type) 243 + } 244 + schema.list_type(schema.non_null(item_input_type)) 245 + } 246 + option.None -> schema.list_type(schema.non_null(schema.string_type())) 247 + } 248 + } 249 + 250 + // For unions - handled differently based on variant count 251 + // Single-variant: use variant type directly 252 + // Multi-variant: caller should use build_multi_variant_union_input 253 + "union" -> { 254 + case property.refs { 255 + option.Some([single_ref]) -> { 256 + // Single-variant union: use that variant's input type directly 257 + case dict.get(existing_input_types, single_ref) { 258 + Ok(input_type) -> input_type 259 + Error(_) -> type_mapper.map_input_type("union") 260 + } 261 + } 262 + option.Some(_multiple_refs) -> { 263 + // Multi-variant: return placeholder, caller handles this 264 + type_mapper.map_input_type("union") 265 + } 266 + _ -> type_mapper.map_input_type("union") 267 + } 268 + } 269 + 270 + // Default: use type_mapper 271 + other -> type_mapper.map_input_type(other) 272 + } 273 + } 274 + 275 + /// Capitalize the first letter of a string 276 + fn capitalize_first(s: String) -> String { 277 + case string.pop_grapheme(s) { 278 + Ok(#(first, rest)) -> string.uppercase(first) <> rest 279 + Error(_) -> s 280 + } 281 + } 282 + 283 + /// Get an input type from the registry by ref 284 + pub fn get_input_type( 285 + registry: UnionRegistry, 286 + ref: String, 287 + ) -> Option(schema.Type) { 288 + dict.get(registry.input_types, ref) |> option.from_result 289 + } 290 + 291 + /// Check if a union has multiple variants 292 + pub fn is_multi_variant_union(refs: Option(List(String))) -> Bool { 293 + case refs { 294 + option.Some([_, _, ..]) -> True 295 + _ -> False 296 + } 297 + } 298 + 299 + /// Find the ref that matches an enum value 300 + pub fn enum_value_to_ref( 301 + enum_value: String, 302 + refs: List(String), 303 + ) -> Option(String) { 304 + list.find(refs, fn(ref) { ref_to_variant_enum_value(ref) == enum_value }) 305 + |> option.from_result 306 + }
+5
lexicon_graphql/src/lexicon_graphql/internal/lexicon/registry.gleam
··· 117 117 registry.object_defs 118 118 |> dict.keys 119 119 } 120 + 121 + /// Get all object definitions from the registry 122 + pub fn get_all_object_defs(registry: Registry) -> Dict(String, types.ObjectDef) { 123 + registry.object_defs 124 + }
+130 -44
lexicon_graphql/src/lexicon_graphql/mutation/builder.gleam
··· 9 9 import gleam/list 10 10 import gleam/option 11 11 import lexicon_graphql/internal/graphql/type_mapper 12 + import lexicon_graphql/internal/graphql/union_input_builder 12 13 import lexicon_graphql/internal/lexicon/nsid 14 + import lexicon_graphql/internal/lexicon/registry as lexicon_registry 13 15 import lexicon_graphql/types 14 16 import swell/schema 15 17 import swell/value ··· 24 26 pub type UploadBlobResolverFactory = 25 27 fn() -> schema.Resolver 26 28 29 + /// Result of building mutation type - includes the type and union field registry 30 + /// 31 + /// The union_registry is used during schema generation to create proper GraphQL 32 + /// input types for AT Protocol union fields. At runtime, mutation resolvers use 33 + /// lexicon JSON directly to transform union inputs (matching the blob transformation 34 + /// pattern), so union_registry is not needed after schema building. 35 + pub type MutationBuildResult { 36 + MutationBuildResult( 37 + mutation_type: schema.Type, 38 + union_registry: union_input_builder.UnionRegistry, 39 + ) 40 + } 41 + 27 42 /// Build a GraphQL Mutation type from lexicon definitions 28 43 /// 29 44 /// For each record type, generates: ··· 32 47 /// - delete{TypeName}(rkey: String!): {TypeName} 33 48 /// 34 49 /// Also adds uploadBlob mutation if upload_blob_factory is provided 50 + /// Custom fields can be passed to add additional mutations beyond collection-based ones 35 51 /// 36 52 /// Resolver factories are optional - if None, mutations will return errors 53 + /// Registry parameter enables union input type generation 37 54 pub fn build_mutation_type( 38 55 lexicons: List(types.Lexicon), 39 56 object_types: dict.Dict(String, schema.Type), ··· 41 58 update_factory: option.Option(ResolverFactory), 42 59 delete_factory: option.Option(ResolverFactory), 43 60 upload_blob_factory: option.Option(UploadBlobResolverFactory), 44 - ) -> schema.Type { 61 + custom_fields: option.Option(List(schema.Field)), 62 + registry: option.Option(lexicon_registry.Registry), 63 + ) -> MutationBuildResult { 64 + // Build union input types if registry is provided 65 + let initial_union_registry = case registry { 66 + option.Some(reg) -> union_input_builder.build_union_input_types(reg) 67 + option.None -> 68 + union_input_builder.UnionRegistry( 69 + input_types: dict.new(), 70 + field_variants: dict.new(), 71 + ) 72 + } 73 + 45 74 // Extract record types 46 75 let record_types = extract_record_types(lexicons) 47 76 48 - // Build mutation fields for each record type using complete object types 49 - let record_mutation_fields = 50 - list.flat_map(record_types, fn(record) { 51 - build_mutations_for_record( 52 - record, 53 - object_types, 54 - create_factory, 55 - update_factory, 56 - delete_factory, 57 - ) 77 + // Build mutation fields for each record type, accumulating union field registrations 78 + let #(record_mutation_fields, final_union_registry) = 79 + list.fold(record_types, #([], initial_union_registry), fn(acc, record) { 80 + let #(fields_acc, registry_acc) = acc 81 + let #(new_fields, updated_registry) = 82 + build_mutations_for_record( 83 + record, 84 + object_types, 85 + create_factory, 86 + update_factory, 87 + delete_factory, 88 + registry_acc, 89 + ) 90 + #(list.append(fields_acc, new_fields), updated_registry) 58 91 }) 59 92 60 93 // Add uploadBlob mutation if factory is provided 61 - let all_mutation_fields = case upload_blob_factory { 94 + let with_upload_blob = case upload_blob_factory { 62 95 option.Some(factory) -> { 63 96 let upload_blob_mutation = build_upload_blob_mutation(factory) 64 97 [upload_blob_mutation, ..record_mutation_fields] ··· 66 99 option.None -> record_mutation_fields 67 100 } 68 101 102 + // Add custom fields if provided 103 + let all_mutation_fields = case custom_fields { 104 + option.Some(fields) -> list.append(with_upload_blob, fields) 105 + option.None -> with_upload_blob 106 + } 107 + 69 108 // Build the Mutation object type 70 - schema.object_type("Mutation", "Root mutation type", all_mutation_fields) 109 + let mutation_type = 110 + schema.object_type("Mutation", "Root mutation type", all_mutation_fields) 111 + 112 + MutationBuildResult( 113 + mutation_type: mutation_type, 114 + union_registry: final_union_registry, 115 + ) 71 116 } 72 117 73 118 /// Record type info needed for building mutations ··· 103 148 create_factory: option.Option(ResolverFactory), 104 149 update_factory: option.Option(ResolverFactory), 105 150 delete_factory: option.Option(ResolverFactory), 106 - ) -> List(schema.Field) { 151 + union_registry: union_input_builder.UnionRegistry, 152 + ) -> #(List(schema.Field), union_input_builder.UnionRegistry) { 153 + // Build input type and get updated registry with union field mappings 154 + let #(input_type, updated_registry) = 155 + build_input_type_with_registry( 156 + record.type_name <> "Input", 157 + record.nsid, 158 + record.properties, 159 + union_registry, 160 + ) 161 + 107 162 let create_mutation = 108 - build_create_mutation(record, object_types, create_factory) 163 + build_create_mutation(record, object_types, create_factory, input_type) 109 164 let update_mutation = 110 - build_update_mutation(record, object_types, update_factory) 165 + build_update_mutation(record, object_types, update_factory, input_type) 111 166 let delete_mutation = build_delete_mutation(record, delete_factory) 112 167 113 - [create_mutation, update_mutation, delete_mutation] 168 + #([create_mutation, update_mutation, delete_mutation], updated_registry) 114 169 } 115 170 116 171 /// Build create mutation for a record type ··· 119 174 record: RecordInfo, 120 175 object_types: dict.Dict(String, schema.Type), 121 176 factory: option.Option(ResolverFactory), 177 + input_type: schema.Type, 122 178 ) -> schema.Field { 123 179 let mutation_name = "create" <> record.type_name 124 - let input_type_name = record.type_name <> "Input" 125 - 126 - // Build the input type 127 - let input_type = build_input_type(input_type_name, record.properties) 128 180 129 181 // Get the complete object type from the dict (includes all join fields) 130 182 let assert Ok(return_type) = dict.get(object_types, record.nsid) ··· 170 222 record: RecordInfo, 171 223 object_types: dict.Dict(String, schema.Type), 172 224 factory: option.Option(ResolverFactory), 225 + input_type: schema.Type, 173 226 ) -> schema.Field { 174 227 let mutation_name = "update" <> record.type_name 175 - let input_type_name = record.type_name <> "Input" 176 - 177 - // Build the input type 178 - let input_type = build_input_type(input_type_name, record.properties) 179 228 180 229 // Get the complete object type from the dict (includes all join fields) 181 230 let assert Ok(return_type) = dict.get(object_types, record.nsid) ··· 255 304 ) 256 305 } 257 306 258 - /// Build an InputObjectType from lexicon properties 259 - fn build_input_type( 307 + /// Build an InputObjectType and register union fields 308 + fn build_input_type_with_registry( 260 309 type_name: String, 310 + collection: String, 261 311 properties: List(#(String, types.Property)), 262 - ) -> schema.Type { 263 - let input_fields = 264 - list.map(properties, fn(prop) { 265 - let #(name, types.Property(type_, required, _, _, _, _)) = prop 266 - // Use map_input_type to get input-compatible types (e.g., BlobInput instead of Blob) 267 - let graphql_type = type_mapper.map_input_type(type_) 312 + union_registry: union_input_builder.UnionRegistry, 313 + ) -> #(schema.Type, union_input_builder.UnionRegistry) { 314 + // Create context for union input type resolution 315 + let ctx = 316 + type_mapper.UnionInputContext( 317 + input_types: union_registry.input_types, 318 + parent_type_name: type_name, 319 + ) 320 + 321 + // Build fields and register union fields 322 + let #(input_fields, final_registry) = 323 + list.fold(properties, #([], union_registry), fn(acc, prop) { 324 + let #(fields_acc, registry_acc) = acc 325 + let #(name, types.Property(type_, required, _, ref, refs, items)) = prop 326 + 327 + // Build property for type mapping 328 + let property = 329 + types.Property(type_, required, option.None, ref, refs, items) 330 + 331 + // Use union-aware type mapping with field name for multi-variant naming 332 + let graphql_type = 333 + type_mapper.map_input_type_with_unions(property, name, ctx) 334 + 335 + // Register union fields for later transformation 336 + let updated_registry = case type_, refs { 337 + "union", option.Some(ref_list) -> { 338 + union_input_builder.register_union_field( 339 + registry_acc, 340 + collection, 341 + name, 342 + ref_list, 343 + ) 344 + } 345 + _, _ -> registry_acc 346 + } 268 347 269 348 // Make required fields non-null 270 349 let field_type = case required { ··· 272 351 False -> graphql_type 273 352 } 274 353 275 - schema.input_field( 276 - name, 277 - field_type, 278 - "Input field for " <> name, 279 - option.None, 280 - ) 354 + let input_field = 355 + schema.input_field( 356 + name, 357 + field_type, 358 + "Input field for " <> name, 359 + option.None, 360 + ) 361 + 362 + #([input_field, ..fields_acc], updated_registry) 281 363 }) 282 364 283 - schema.input_object_type( 284 - type_name, 285 - "Input type for " <> type_name, 286 - input_fields, 287 - ) 365 + let reversed_fields = list.reverse(input_fields) 366 + let input_type = 367 + schema.input_object_type( 368 + type_name, 369 + "Input type for " <> type_name, 370 + reversed_fields, 371 + ) 372 + 373 + #(input_type, final_registry) 288 374 } 289 375 290 376 /// Build a simple deletion result type that only contains URI
+7 -2
lexicon_graphql/src/lexicon_graphql/schema/builder.gleam
··· 65 65 let query_type = build_query_type(record_types, object_types) 66 66 67 67 // Build the mutation type with stub resolvers, using shared object types 68 - let mutation_type = 68 + let mutation_build_result = 69 69 mutation_builder.build_mutation_type( 70 70 lexicons, 71 71 object_types, 72 + option.None, 73 + option.None, 72 74 option.None, 73 75 option.None, 74 76 option.None, ··· 76 78 ) 77 79 78 80 // Create the schema with queries and mutations 79 - Ok(schema.schema(query_type, option.Some(mutation_type))) 81 + Ok(schema.schema( 82 + query_type, 83 + option.Some(mutation_build_result.mutation_type), 84 + )) 80 85 } 81 86 } 82 87 }
+237 -5
lexicon_graphql/src/lexicon_graphql/schema/database.gleam
··· 141 141 String, 142 142 ) 143 143 144 + /// Type for labels batch fetcher function 145 + /// Takes: list of (URI, optional record JSON) tuples 146 + /// Returns: dict mapping URI -> List of label values 147 + pub type LabelsFetcher = 148 + fn(List(#(String, option.Option(String)))) -> 149 + Result(dict.Dict(String, List(value.Value)), String) 150 + 144 151 /// Build a GraphQL schema from lexicons with database-backed resolvers 145 152 /// 146 153 /// The fetcher parameter should be a function that queries the database for records with pagination ··· 167 174 paginated_batch_fetcher, 168 175 option.None, 169 176 // No viewer state fetcher for basic schema 177 + option.None, 178 + // No labels fetcher for basic schema 170 179 ) 171 180 172 181 // Build the query type with fields for each record using shared object types ··· 183 192 option.None, 184 193 option.None, 185 194 option.None, 195 + option.None, 186 196 ) 187 197 188 198 // Build the mutation type with provided resolver factories 189 199 // Pass the complete object types so mutations use the same types as queries 190 - let mutation_type = 200 + let mutation_build_result = 191 201 mutation_builder.build_mutation_type( 192 202 lexicons, 193 203 object_types, ··· 195 205 update_factory, 196 206 delete_factory, 197 207 upload_blob_factory, 208 + option.None, 209 + option.None, 198 210 ) 199 211 200 212 // Create the schema with both queries and mutations 201 - Ok(schema.schema(query_type, option.Some(mutation_type))) 213 + Ok(schema.schema( 214 + query_type, 215 + option.Some(mutation_build_result.mutation_type), 216 + )) 202 217 } 203 218 } 204 219 } ··· 220 235 viewer_fetcher: option.Option(ViewerFetcher), 221 236 notification_fetcher: option.Option(NotificationFetcher), 222 237 viewer_state_fetcher: option.Option(dataloader.ViewerStateFetcher), 238 + labels_fetcher: option.Option(LabelsFetcher), 239 + custom_mutation_fields: option.Option(List(schema.Field)), 240 + custom_query_fields: option.Option(List(schema.Field)), 223 241 ) -> Result(schema.Schema, String) { 224 242 case lexicons { 225 243 [] -> Error("Cannot build schema from empty lexicon list") ··· 231 249 batch_fetcher, 232 250 paginated_batch_fetcher, 233 251 viewer_state_fetcher, 252 + labels_fetcher, 234 253 ) 235 254 236 255 // Build notification types if fetcher provided ··· 257 276 notification_fetcher, 258 277 notification_union, 259 278 collection_enum, 279 + custom_query_fields, 260 280 ) 261 281 262 282 // Build the mutation type 263 - let mutation_type = 283 + let mutation_build_result = 264 284 mutation_builder.build_mutation_type( 265 285 lexicons, 266 286 object_types, ··· 268 288 update_factory, 269 289 delete_factory, 270 290 upload_blob_factory, 291 + custom_mutation_fields, 292 + option.None, 271 293 ) 272 294 273 295 // Build the subscription type ··· 282 304 // Create the schema with queries, mutations, and subscriptions 283 305 Ok(schema.schema_with_subscriptions( 284 306 query_type, 285 - option.Some(mutation_type), 307 + option.Some(mutation_build_result.mutation_type), 286 308 option.Some(subscription_type), 287 309 )) 288 310 } ··· 303 325 batch_fetcher: option.Option(dataloader.BatchFetcher), 304 326 paginated_batch_fetcher: option.Option(dataloader.PaginatedBatchFetcher), 305 327 viewer_state_fetcher: option.Option(dataloader.ViewerStateFetcher), 328 + labels_fetcher: option.Option(LabelsFetcher), 306 329 ) -> #( 307 330 List(RecordType), 308 331 dict.Dict(String, schema.Type), ··· 632 655 complete_object_types, 633 656 ) 634 657 658 + // Replace lexicon's labels field resolver if it exists, otherwise use as-is 659 + let enhanced_fields = case 660 + has_self_labels_property(record_type.properties) 661 + { 662 + True -> 663 + replace_labels_field_resolver(record_type.fields, labels_fetcher) 664 + False -> record_type.fields 665 + } 666 + 667 + // Build labels field if labels_fetcher is provided (skipped if lexicon defines it) 668 + let labels_field = 669 + build_labels_field(labels_fetcher, record_type.properties) 670 + 635 671 // Combine all fields 636 672 let all_fields = 637 673 list.flatten([ 638 - record_type.fields, 674 + enhanced_fields, 639 675 forward_join_fields, 640 676 reverse_join_fields, 641 677 did_join_fields, 642 678 viewer_fields, 643 679 did_viewer_fields, 680 + labels_field, 644 681 ]) 645 682 646 683 RecordType( ··· 1847 1884 notification_fetcher: option.Option(NotificationFetcher), 1848 1885 notification_union: option.Option(schema.Type), 1849 1886 collection_enum: option.Option(schema.Type), 1887 + custom_query_fields: option.Option(List(schema.Field)), 1850 1888 ) -> schema.Type { 1851 1889 // Build regular query fields 1852 1890 let query_fields = ··· 2275 2313 ] 2276 2314 } 2277 2315 _, _ -> [] 2316 + } 2317 + 2318 + // Get custom query fields if provided 2319 + let custom_fields = case custom_query_fields { 2320 + option.Some(fields) -> fields 2321 + option.None -> [] 2278 2322 } 2279 2323 2280 2324 // Combine all query fields ··· 2284 2328 aggregate_query_fields, 2285 2329 viewer_field, 2286 2330 notification_fields, 2331 + custom_fields, 2287 2332 ]) 2288 2333 2289 2334 schema.object_type("Query", "Root query type", all_query_fields) ··· 3327 3372 type_resolver, 3328 3373 )) 3329 3374 } 3375 + 3376 + /// Build the Label GraphQL type used for moderation labels 3377 + fn build_label_type() -> schema.Type { 3378 + schema.object_type("Label", "AT Protocol moderation label", [ 3379 + schema.field("id", schema.non_null(schema.int_type()), "Label ID", fn(ctx) { 3380 + get_field_from_parent(ctx, "id") 3381 + }), 3382 + schema.field( 3383 + "src", 3384 + schema.non_null(schema.string_type()), 3385 + "DID of the label source (labeler)", 3386 + fn(ctx) { get_field_from_parent(ctx, "src") }, 3387 + ), 3388 + schema.field( 3389 + "uri", 3390 + schema.non_null(schema.string_type()), 3391 + "AT-URI of the subject", 3392 + fn(ctx) { get_field_from_parent(ctx, "uri") }, 3393 + ), 3394 + schema.field( 3395 + "cid", 3396 + schema.string_type(), 3397 + "Optional CID of the subject", 3398 + fn(ctx) { get_field_from_parent(ctx, "cid") }, 3399 + ), 3400 + schema.field( 3401 + "val", 3402 + schema.non_null(schema.string_type()), 3403 + "Label value (e.g., '!takedown', 'porn')", 3404 + fn(ctx) { get_field_from_parent(ctx, "val") }, 3405 + ), 3406 + schema.field( 3407 + "neg", 3408 + schema.non_null(schema.boolean_type()), 3409 + "True if this is a negation of the label", 3410 + fn(ctx) { get_field_from_parent(ctx, "neg") }, 3411 + ), 3412 + schema.field( 3413 + "cts", 3414 + schema.non_null(schema.string_type()), 3415 + "Creation timestamp", 3416 + fn(ctx) { get_field_from_parent(ctx, "cts") }, 3417 + ), 3418 + schema.field( 3419 + "exp", 3420 + schema.string_type(), 3421 + "Optional expiration timestamp", 3422 + fn(ctx) { get_field_from_parent(ctx, "exp") }, 3423 + ), 3424 + ]) 3425 + } 3426 + 3427 + /// Build labels field for record types if labels_fetcher is provided 3428 + /// Returns a list with one field, or empty list if no fetcher 3429 + /// Skips adding the field if the lexicon already defines a labels field with selfLabels 3430 + fn build_labels_field( 3431 + labels_fetcher: option.Option(LabelsFetcher), 3432 + properties: List(#(String, types.Property)), 3433 + ) -> List(schema.Field) { 3434 + case labels_fetcher { 3435 + option.None -> [] 3436 + option.Some(fetcher) -> { 3437 + // Skip if lexicon already defines a labels field - we'll replace its resolver instead 3438 + case has_self_labels_property(properties) { 3439 + True -> [] 3440 + False -> { 3441 + let label_type = build_label_type() 3442 + [ 3443 + schema.field( 3444 + "labels", 3445 + schema.non_null(schema.list_type(schema.non_null(label_type))), 3446 + "Moderation labels applied to this record", 3447 + fn(ctx) { 3448 + // Get the URI and JSON from the parent record 3449 + case get_field_from_context(ctx, "uri") { 3450 + Ok(uri_str) -> { 3451 + let json_opt = case get_field_from_context(ctx, "json") { 3452 + Ok(j) -> option.Some(j) 3453 + Error(_) -> option.None 3454 + } 3455 + // Fetch labels for this URI with optional JSON for self-labels 3456 + case fetcher([#(uri_str, json_opt)]) { 3457 + Ok(results) -> { 3458 + case dict.get(results, uri_str) { 3459 + Ok(labels) -> Ok(value.List(labels)) 3460 + Error(_) -> Ok(value.List([])) 3461 + } 3462 + } 3463 + Error(_) -> Ok(value.List([])) 3464 + } 3465 + } 3466 + Error(_) -> Ok(value.List([])) 3467 + } 3468 + }, 3469 + ), 3470 + ] 3471 + } 3472 + } 3473 + } 3474 + } 3475 + } 3476 + 3477 + /// Check if a record type has a labels property with selfLabels ref 3478 + /// Handles both direct ref and union refs array 3479 + fn has_self_labels_property(properties: List(#(String, types.Property))) -> Bool { 3480 + list.any(properties, fn(prop) { 3481 + let #(name, types.Property(_, _, _, ref, refs, _)) = prop 3482 + case name == "labels" { 3483 + False -> False 3484 + True -> { 3485 + // Check direct ref 3486 + let has_direct_ref = 3487 + ref == option.Some("com.atproto.label.defs#selfLabels") 3488 + // Check union refs array 3489 + let has_union_ref = case refs { 3490 + option.Some(ref_list) -> 3491 + list.contains(ref_list, "com.atproto.label.defs#selfLabels") 3492 + option.None -> False 3493 + } 3494 + has_direct_ref || has_union_ref 3495 + } 3496 + } 3497 + }) 3498 + } 3499 + 3500 + /// Replace the labels field resolver with an enhanced version that merges moderator labels 3501 + fn replace_labels_field_resolver( 3502 + fields: List(schema.Field), 3503 + labels_fetcher: option.Option(LabelsFetcher), 3504 + ) -> List(schema.Field) { 3505 + case labels_fetcher { 3506 + option.None -> fields 3507 + option.Some(fetcher) -> { 3508 + let label_type = build_label_type() 3509 + list.map(fields, fn(field) { 3510 + case schema.field_name(field) == "labels" { 3511 + False -> field 3512 + True -> { 3513 + // Replace with enhanced resolver that fetches moderator labels 3514 + // Use our Label type instead of the lexicon's selfLabels type 3515 + schema.field( 3516 + "labels", 3517 + schema.non_null(schema.list_type(schema.non_null(label_type))), 3518 + "Labels applied to this record (self-labels and moderator labels)", 3519 + fn(ctx) { 3520 + case get_field_from_context(ctx, "uri") { 3521 + Ok(uri_str) -> { 3522 + let json_opt = case get_field_from_context(ctx, "json") { 3523 + Ok(j) -> option.Some(j) 3524 + Error(_) -> option.None 3525 + } 3526 + case fetcher([#(uri_str, json_opt)]) { 3527 + Ok(results) -> { 3528 + case dict.get(results, uri_str) { 3529 + Ok(labels) -> Ok(value.List(labels)) 3530 + Error(_) -> Ok(value.List([])) 3531 + } 3532 + } 3533 + Error(_) -> Ok(value.List([])) 3534 + } 3535 + } 3536 + Error(_) -> Ok(value.List([])) 3537 + } 3538 + }, 3539 + ) 3540 + } 3541 + } 3542 + }) 3543 + } 3544 + } 3545 + } 3546 + 3547 + /// Helper to get a field value from the parent context 3548 + fn get_field_from_parent( 3549 + ctx: schema.Context, 3550 + field_name: String, 3551 + ) -> Result(value.Value, String) { 3552 + case ctx.data { 3553 + option.Some(value.Object(fields)) -> { 3554 + case list.key_find(fields, field_name) { 3555 + Ok(v) -> Ok(v) 3556 + Error(_) -> Error("Field " <> field_name <> " not found in parent") 3557 + } 3558 + } 3559 + _ -> Error("Parent is not an object") 3560 + } 3561 + }
+48 -21
lexicon_graphql/test/mutation_builder_test.gleam
··· 25 25 } 26 26 27 27 // Build mutation type with uploadBlob factory 28 - let mutation_type = 28 + let mutation_build_result = 29 29 mutation_builder.build_mutation_type( 30 30 [], 31 31 dict.new(), ··· 33 33 None, 34 34 None, 35 35 Some(upload_factory), 36 + None, 37 + None, 36 38 ) 37 39 38 40 // Verify the mutation type has uploadBlob field 39 - let fields = schema.get_fields(mutation_type) 41 + let fields = schema.get_fields(mutation_build_result.mutation_type) 40 42 let has_upload_blob = 41 43 list.any(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 42 44 ··· 47 49 /// Test that uploadBlob mutation is NOT added when factory is None 48 50 pub fn build_mutation_type_without_upload_blob_test() { 49 51 // Build mutation type without uploadBlob factory 50 - let mutation_type = 51 - mutation_builder.build_mutation_type([], dict.new(), None, None, None, None) 52 + let mutation_build_result = 53 + mutation_builder.build_mutation_type( 54 + [], 55 + dict.new(), 56 + None, 57 + None, 58 + None, 59 + None, 60 + None, 61 + None, 62 + ) 52 63 53 64 // Verify the mutation type does NOT have uploadBlob field 54 - let fields = schema.get_fields(mutation_type) 65 + let fields = schema.get_fields(mutation_build_result.mutation_type) 55 66 let has_upload_blob = 56 67 list.any(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 57 68 ··· 75 86 } 76 87 77 88 // Build mutation type 78 - let mutation_type = 89 + let mutation_build_result = 79 90 mutation_builder.build_mutation_type( 80 91 [], 81 92 dict.new(), ··· 83 94 None, 84 95 None, 85 96 Some(upload_factory), 97 + None, 98 + None, 86 99 ) 87 100 88 101 // Get uploadBlob field 89 - let fields = schema.get_fields(mutation_type) 102 + let fields = schema.get_fields(mutation_build_result.mutation_type) 90 103 let upload_blob_field = 91 104 list.find(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 92 105 ··· 121 134 } 122 135 } 123 136 124 - let mutation_type = 137 + let mutation_build_result = 125 138 mutation_builder.build_mutation_type( 126 139 [], 127 140 dict.new(), ··· 129 142 None, 130 143 None, 131 144 Some(upload_factory), 145 + None, 146 + None, 132 147 ) 133 148 134 149 // Get uploadBlob field 135 - let fields = schema.get_fields(mutation_type) 150 + let fields = schema.get_fields(mutation_build_result.mutation_type) 136 151 let upload_blob_field = 137 152 list.find(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 138 153 ··· 177 192 } 178 193 } 179 194 180 - let mutation_type = 195 + let mutation_build_result = 181 196 mutation_builder.build_mutation_type( 182 197 [], 183 198 dict.new(), ··· 185 200 None, 186 201 None, 187 202 Some(upload_factory), 203 + None, 204 + None, 188 205 ) 189 206 190 207 // Get uploadBlob field 191 - let fields = schema.get_fields(mutation_type) 208 + let fields = schema.get_fields(mutation_build_result.mutation_type) 192 209 let upload_blob_field = 193 210 list.find(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 194 211 ··· 233 250 } 234 251 } 235 252 236 - let mutation_type = 253 + let mutation_build_result = 237 254 mutation_builder.build_mutation_type( 238 255 [], 239 256 dict.new(), ··· 241 258 None, 242 259 None, 243 260 Some(upload_factory), 261 + None, 262 + None, 244 263 ) 245 264 246 265 // Get uploadBlob field 247 - let fields = schema.get_fields(mutation_type) 266 + let fields = schema.get_fields(mutation_build_result.mutation_type) 248 267 let upload_blob_field = 249 268 list.find(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 250 269 ··· 280 299 } 281 300 } 282 301 283 - let mutation_type = 302 + let mutation_build_result = 284 303 mutation_builder.build_mutation_type( 285 304 [], 286 305 dict.new(), ··· 288 307 None, 289 308 None, 290 309 Some(upload_factory), 310 + None, 311 + None, 291 312 ) 292 313 293 314 // Get uploadBlob field 294 - let fields = schema.get_fields(mutation_type) 315 + let fields = schema.get_fields(mutation_build_result.mutation_type) 295 316 let upload_blob_field = 296 317 list.find(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 297 318 ··· 326 347 } 327 348 } 328 349 329 - let mutation_type = 350 + let mutation_build_result = 330 351 mutation_builder.build_mutation_type( 331 352 [], 332 353 dict.new(), ··· 334 355 None, 335 356 None, 336 357 Some(upload_factory), 358 + None, 359 + None, 337 360 ) 338 361 339 362 // Get uploadBlob field 340 - let fields = schema.get_fields(mutation_type) 363 + let fields = schema.get_fields(mutation_build_result.mutation_type) 341 364 let upload_blob_field = 342 365 list.find(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 343 366 ··· 374 397 } 375 398 } 376 399 377 - let mutation_type = 400 + let mutation_build_result = 378 401 mutation_builder.build_mutation_type( 379 402 [], 380 403 dict.new(), ··· 382 405 None, 383 406 None, 384 407 Some(upload_factory), 408 + None, 409 + None, 385 410 ) 386 411 387 412 // Get uploadBlob field 388 - let fields = schema.get_fields(mutation_type) 413 + let fields = schema.get_fields(mutation_build_result.mutation_type) 389 414 let upload_blob_field = 390 415 list.find(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 391 416 ··· 420 445 } 421 446 } 422 447 423 - let mutation_type = 448 + let mutation_build_result = 424 449 mutation_builder.build_mutation_type( 425 450 [], 426 451 dict.new(), ··· 428 453 None, 429 454 None, 430 455 Some(upload_factory), 456 + None, 457 + None, 431 458 ) 432 459 433 460 // Get uploadBlob field 434 - let fields = schema.get_fields(mutation_type) 461 + let fields = schema.get_fields(mutation_build_result.mutation_type) 435 462 let upload_blob_field = 436 463 list.find(fields, fn(field) { schema.field_name(field) == "uploadBlob" }) 437 464
+3
lexicon_graphql/test/sorting_test.gleam
··· 50 50 option.None, 51 51 option.None, 52 52 option.None, 53 + option.None, 54 + option.None, 55 + option.None, 53 56 ) 54 57 { 55 58 Ok(s) -> s
+15 -20
lexicon_graphql/test/subscription_schema_test.gleam
··· 57 57 None, 58 58 None, 59 59 None, 60 - // aggregate_fetcher 60 + None, 61 + None, 62 + None, 61 63 None, 62 - // viewer_fetcher 63 64 None, 64 - // notification_fetcher 65 65 None, 66 - // viewer_state_fetcher 67 66 ) 68 67 { 69 68 Ok(s) -> { ··· 114 113 None, 115 114 None, 116 115 None, 117 - // aggregate_fetcher 116 + None, 117 + None, 118 + None, 118 119 None, 119 - // viewer_fetcher 120 120 None, 121 - // notification_fetcher 122 121 None, 123 - // viewer_state_fetcher 124 122 ) 125 123 { 126 124 Ok(s) -> { ··· 193 191 None, 194 192 None, 195 193 None, 196 - // aggregate_fetcher 194 + None, 195 + None, 196 + None, 197 197 None, 198 - // viewer_fetcher 199 198 None, 200 - // notification_fetcher 201 199 None, 202 - // viewer_state_fetcher 203 200 ) 204 201 { 205 202 Ok(s) -> { ··· 265 262 None, 266 263 None, 267 264 None, 268 - // aggregate_fetcher 265 + None, 266 + None, 267 + None, 269 268 None, 270 - // viewer_fetcher 271 269 None, 272 - // notification_fetcher 273 270 None, 274 - // viewer_state_fetcher 275 271 ) 276 272 { 277 273 Ok(s) -> { ··· 357 353 None, 358 354 None, 359 355 None, 360 - // aggregate_fetcher 356 + None, 357 + None, 358 + None, 361 359 None, 362 - // viewer_fetcher 363 360 None, 364 - // notification_fetcher 365 361 None, 366 - // viewer_state_fetcher 367 362 ) 368 363 { 369 364 Ok(s) -> {
+275
lexicon_graphql/test/union_input_builder_test.gleam
··· 1 + /// Tests for Union Input Builder 2 + /// 3 + /// Tests the generation of GraphQL input types for AT Protocol union fields 4 + import gleam/dict 5 + import gleam/option.{None, Some} 6 + import gleeunit/should 7 + import lexicon_graphql/internal/graphql/union_input_builder 8 + import lexicon_graphql/internal/lexicon/registry 9 + import lexicon_graphql/types 10 + import swell/schema 11 + 12 + // ─── ref_to_input_type_name Tests ────────────────────────────────── 13 + 14 + pub fn ref_to_input_type_name_with_hash_test() { 15 + union_input_builder.ref_to_input_type_name( 16 + "com.atproto.label.defs#selfLabels", 17 + ) 18 + |> should.equal("ComAtprotoLabelDefsSelfLabelsInput") 19 + } 20 + 21 + pub fn ref_to_input_type_name_without_hash_test() { 22 + union_input_builder.ref_to_input_type_name("com.atproto.label.defs") 23 + |> should.equal("ComAtprotoLabelDefsInput") 24 + } 25 + 26 + // ─── ref_to_variant_enum_value Tests ─────────────────────────────── 27 + 28 + pub fn ref_to_variant_enum_value_with_hash_test() { 29 + union_input_builder.ref_to_variant_enum_value( 30 + "com.atproto.label.defs#selfLabels", 31 + ) 32 + |> should.equal("SELF_LABELS") 33 + } 34 + 35 + pub fn ref_to_variant_enum_value_camel_case_test() { 36 + union_input_builder.ref_to_variant_enum_value( 37 + "com.example.defs#myVariantType", 38 + ) 39 + |> should.equal("MY_VARIANT_TYPE") 40 + } 41 + 42 + pub fn ref_to_variant_enum_value_single_word_test() { 43 + union_input_builder.ref_to_variant_enum_value("com.example.defs#labels") 44 + |> should.equal("LABELS") 45 + } 46 + 47 + pub fn ref_to_variant_enum_value_with_numbers_test() { 48 + // Numbers are not treated as word boundaries 49 + union_input_builder.ref_to_variant_enum_value("com.example.defs#oauth2Client") 50 + |> should.equal("OAUTH2_CLIENT") 51 + } 52 + 53 + // ─── ref_to_variant_field_name Tests ─────────────────────────────── 54 + 55 + pub fn ref_to_variant_field_name_with_hash_test() { 56 + union_input_builder.ref_to_variant_field_name( 57 + "com.atproto.label.defs#selfLabels", 58 + ) 59 + |> should.equal("selfLabels") 60 + } 61 + 62 + pub fn ref_to_variant_field_name_without_hash_test() { 63 + union_input_builder.ref_to_variant_field_name("com.atproto.label.defs") 64 + |> should.equal("defs") 65 + } 66 + 67 + // ─── enum_value_to_short_name Tests ──────────────────────────────── 68 + 69 + pub fn enum_value_to_short_name_multi_word_test() { 70 + union_input_builder.enum_value_to_short_name("SELF_LABELS") 71 + |> should.equal("selfLabels") 72 + } 73 + 74 + pub fn enum_value_to_short_name_single_word_test() { 75 + union_input_builder.enum_value_to_short_name("LABELS") 76 + |> should.equal("labels") 77 + } 78 + 79 + pub fn enum_value_to_short_name_three_words_test() { 80 + union_input_builder.enum_value_to_short_name("MY_VARIANT_TYPE") 81 + |> should.equal("myVariantType") 82 + } 83 + 84 + pub fn enum_value_to_short_name_with_numbers_test() { 85 + // Numbers are preserved correctly 86 + union_input_builder.enum_value_to_short_name("OAUTH2_CLIENT") 87 + |> should.equal("oauth2Client") 88 + } 89 + 90 + // ─── Round-trip Conversion Tests ────────────────────────────────── 91 + 92 + pub fn round_trip_camel_to_snake_and_back_test() { 93 + // Convert camelCase -> SCREAMING_SNAKE -> camelCase 94 + let original = "selfLabels" 95 + let snake = 96 + union_input_builder.ref_to_variant_enum_value("com.example#" <> original) 97 + union_input_builder.enum_value_to_short_name(snake) 98 + |> should.equal(original) 99 + } 100 + 101 + pub fn round_trip_with_multiple_words_test() { 102 + let original = "myVariantType" 103 + let snake = 104 + union_input_builder.ref_to_variant_enum_value("com.example#" <> original) 105 + union_input_builder.enum_value_to_short_name(snake) 106 + |> should.equal(original) 107 + } 108 + 109 + pub fn round_trip_with_numbers_test() { 110 + let original = "oauth2Client" 111 + let snake = 112 + union_input_builder.ref_to_variant_enum_value("com.example#" <> original) 113 + union_input_builder.enum_value_to_short_name(snake) 114 + |> should.equal(original) 115 + } 116 + 117 + // ─── is_multi_variant_union Tests ────────────────────────────────── 118 + 119 + pub fn is_multi_variant_union_with_two_refs_test() { 120 + union_input_builder.is_multi_variant_union(Some(["ref1", "ref2"])) 121 + |> should.be_true() 122 + } 123 + 124 + pub fn is_multi_variant_union_with_one_ref_test() { 125 + union_input_builder.is_multi_variant_union(Some(["ref1"])) 126 + |> should.be_false() 127 + } 128 + 129 + pub fn is_multi_variant_union_with_none_test() { 130 + union_input_builder.is_multi_variant_union(None) 131 + |> should.be_false() 132 + } 133 + 134 + pub fn is_multi_variant_union_with_empty_list_test() { 135 + union_input_builder.is_multi_variant_union(Some([])) 136 + |> should.be_false() 137 + } 138 + 139 + // ─── enum_value_to_ref Tests ─────────────────────────────────────── 140 + 141 + pub fn enum_value_to_ref_finds_match_test() { 142 + let refs = [ 143 + "com.atproto.label.defs#selfLabels", 144 + "com.example.defs#otherType", 145 + ] 146 + 147 + union_input_builder.enum_value_to_ref("SELF_LABELS", refs) 148 + |> should.equal(Some("com.atproto.label.defs#selfLabels")) 149 + } 150 + 151 + pub fn enum_value_to_ref_no_match_test() { 152 + let refs = [ 153 + "com.atproto.label.defs#selfLabels", 154 + "com.example.defs#otherType", 155 + ] 156 + 157 + union_input_builder.enum_value_to_ref("UNKNOWN_TYPE", refs) 158 + |> should.equal(None) 159 + } 160 + 161 + // ─── UnionRegistry Tests ─────────────────────────────────────────── 162 + 163 + pub fn register_union_field_stores_refs_test() { 164 + let initial_registry = 165 + union_input_builder.UnionRegistry( 166 + input_types: dict.new(), 167 + field_variants: dict.new(), 168 + ) 169 + 170 + let updated_registry = 171 + union_input_builder.register_union_field( 172 + initial_registry, 173 + "social.grain.gallery", 174 + "labels", 175 + ["com.atproto.label.defs#selfLabels"], 176 + ) 177 + 178 + union_input_builder.get_union_refs( 179 + updated_registry, 180 + "social.grain.gallery", 181 + "labels", 182 + ) 183 + |> should.equal(Some(["com.atproto.label.defs#selfLabels"])) 184 + } 185 + 186 + pub fn get_union_refs_returns_none_for_missing_field_test() { 187 + let empty_registry = 188 + union_input_builder.UnionRegistry( 189 + input_types: dict.new(), 190 + field_variants: dict.new(), 191 + ) 192 + 193 + union_input_builder.get_union_refs(empty_registry, "unknown", "field") 194 + |> should.equal(None) 195 + } 196 + 197 + // ─── build_multi_variant_union_input Tests ───────────────────────── 198 + 199 + pub fn build_multi_variant_union_input_creates_type_with_discriminator_test() { 200 + let refs = [ 201 + "com.atproto.label.defs#selfLabels", 202 + "com.example.defs#otherLabels", 203 + ] 204 + 205 + let union_type = 206 + union_input_builder.build_multi_variant_union_input( 207 + "GalleryInput", 208 + "labels", 209 + refs, 210 + dict.new(), 211 + ) 212 + 213 + // Verify it's an input object type 214 + schema.is_input_object(union_type) 215 + |> should.be_true() 216 + 217 + // Verify the type name 218 + schema.type_name(union_type) 219 + |> should.equal("GalleryInputLabelsInput") 220 + } 221 + 222 + // ─── build_union_input_types with lexicons Tests ─────────────────── 223 + 224 + pub fn build_union_input_types_from_empty_lexicons_test() { 225 + // Create a registry from empty lexicons 226 + let empty_registry = registry.from_lexicons([]) 227 + 228 + let union_registry = 229 + union_input_builder.build_union_input_types(empty_registry) 230 + 231 + // Should have empty input_types and field_variants 232 + dict.size(union_registry.input_types) 233 + |> should.equal(0) 234 + 235 + dict.size(union_registry.field_variants) 236 + |> should.equal(0) 237 + } 238 + 239 + pub fn build_union_input_types_generates_input_for_object_def_test() { 240 + // Create a lexicon with an object def in "others" 241 + let obj_def = 242 + types.ObjectDef(type_: "object", required_fields: ["values"], properties: [ 243 + #( 244 + "values", 245 + types.Property( 246 + type_: "array", 247 + required: True, 248 + format: None, 249 + ref: None, 250 + refs: None, 251 + items: Some(types.ArrayItems(type_: "string", ref: None, refs: None)), 252 + ), 253 + ), 254 + ]) 255 + 256 + let lexicon = 257 + types.Lexicon( 258 + id: "com.atproto.label.defs", 259 + defs: types.Defs( 260 + main: None, 261 + others: dict.from_list([#("selfLabels", types.Object(obj_def))]), 262 + ), 263 + ) 264 + 265 + let reg = registry.from_lexicons([lexicon]) 266 + let union_registry = union_input_builder.build_union_input_types(reg) 267 + 268 + // Should have an input type for the ref 269 + union_input_builder.get_input_type( 270 + union_registry, 271 + "com.atproto.label.defs#selfLabels", 272 + ) 273 + |> option.is_some 274 + |> should.be_true() 275 + }
+136
lexicon_graphql/test/union_input_test.gleam
··· 1 + /// Tests for Union Input 2 + /// 3 + /// Tests the transformation of GraphQL discriminated union inputs 4 + /// to AT Protocol $type format. 5 + import gleeunit/should 6 + import lexicon_graphql/input/union as union_input 7 + import swell/value 8 + 9 + // ─── transform_union_object Tests ────────────────────────────────── 10 + 11 + pub fn transform_union_object_with_enum_type_test() { 12 + // GraphQL input format with enum discriminator 13 + let fields = [ 14 + #("type", value.Enum("SELF_LABELS")), 15 + #( 16 + "selfLabels", 17 + value.Object([ 18 + #("values", value.List([value.String("!no-unauthenticated")])), 19 + ]), 20 + ), 21 + ] 22 + let refs = ["com.atproto.label.defs#selfLabels"] 23 + 24 + let result = union_input.transform_union_object(fields, refs) 25 + 26 + // Should transform to AT Protocol format 27 + result 28 + |> should.equal( 29 + value.Object([ 30 + #("$type", value.String("com.atproto.label.defs#selfLabels")), 31 + #("values", value.List([value.String("!no-unauthenticated")])), 32 + ]), 33 + ) 34 + } 35 + 36 + pub fn transform_union_object_with_string_type_test() { 37 + // GraphQL input format with string discriminator (fallback) 38 + let fields = [ 39 + #("type", value.String("SELF_LABELS")), 40 + #("selfLabels", value.Object([#("values", value.List([]))])), 41 + ] 42 + let refs = ["com.atproto.label.defs#selfLabels"] 43 + 44 + let result = union_input.transform_union_object(fields, refs) 45 + 46 + result 47 + |> should.equal( 48 + value.Object([ 49 + #("$type", value.String("com.atproto.label.defs#selfLabels")), 50 + #("values", value.List([])), 51 + ]), 52 + ) 53 + } 54 + 55 + pub fn transform_union_object_multi_variant_test() { 56 + // Test with multiple possible refs 57 + let fields = [ 58 + #("type", value.Enum("OTHER_LABELS")), 59 + #("otherLabels", value.Object([#("data", value.String("test"))])), 60 + #("selfLabels", value.Null), 61 + ] 62 + let refs = [ 63 + "com.atproto.label.defs#selfLabels", 64 + "com.example.defs#otherLabels", 65 + ] 66 + 67 + let result = union_input.transform_union_object(fields, refs) 68 + 69 + result 70 + |> should.equal( 71 + value.Object([ 72 + #("$type", value.String("com.example.defs#otherLabels")), 73 + #("data", value.String("test")), 74 + ]), 75 + ) 76 + } 77 + 78 + pub fn transform_union_object_no_variant_data_test() { 79 + // When variant field is missing or not an object 80 + let fields = [#("type", value.Enum("SELF_LABELS"))] 81 + let refs = ["com.atproto.label.defs#selfLabels"] 82 + 83 + let result = union_input.transform_union_object(fields, refs) 84 + 85 + // Should still output $type 86 + result 87 + |> should.equal( 88 + value.Object([#("$type", value.String("com.atproto.label.defs#selfLabels"))]), 89 + ) 90 + } 91 + 92 + pub fn transform_union_object_unknown_type_test() { 93 + // When type doesn't match any ref 94 + let fields = [ 95 + #("type", value.Enum("UNKNOWN_TYPE")), 96 + #("someData", value.String("test")), 97 + ] 98 + let refs = ["com.atproto.label.defs#selfLabels"] 99 + 100 + let result = union_input.transform_union_object(fields, refs) 101 + 102 + // Should return original fields unchanged 103 + result 104 + |> should.equal(value.Object(fields)) 105 + } 106 + 107 + pub fn transform_union_object_no_type_field_test() { 108 + // When there's no type discriminator 109 + let fields = [#("someField", value.String("value"))] 110 + let refs = ["com.atproto.label.defs#selfLabels"] 111 + 112 + let result = union_input.transform_union_object(fields, refs) 113 + 114 + // Should return original fields unchanged 115 + result 116 + |> should.equal(value.Object(fields)) 117 + } 118 + 119 + pub fn transform_union_object_with_numbers_in_name_test() { 120 + // Test edge case with numbers in variant name 121 + let fields = [ 122 + #("type", value.Enum("OAUTH2_CLIENT")), 123 + #("oauth2Client", value.Object([#("id", value.String("client123"))])), 124 + ] 125 + let refs = ["com.example.oauth#oauth2Client"] 126 + 127 + let result = union_input.transform_union_object(fields, refs) 128 + 129 + result 130 + |> should.equal( 131 + value.Object([ 132 + #("$type", value.String("com.example.oauth#oauth2Client")), 133 + #("id", value.String("client123")), 134 + ]), 135 + ) 136 + }
+3
lexicon_graphql/test/where_schema_test.gleam
··· 50 50 option.None, 51 51 option.None, 52 52 option.None, 53 + option.None, 54 + option.None, 55 + option.None, 53 56 ) 54 57 { 55 58 Ok(s) -> s
+81
server/db/migrations/20251229000001_add_labels_and_reports.sql
··· 1 + -- migrate:up 2 + 3 + -- ============================================================================= 4 + -- Label Definition Table 5 + -- ============================================================================= 6 + 7 + -- Defines available label values for this instance 8 + CREATE TABLE IF NOT EXISTS label_definition ( 9 + val TEXT PRIMARY KEY NOT NULL, 10 + description TEXT NOT NULL, 11 + severity TEXT NOT NULL CHECK (severity IN ('inform', 'alert', 'takedown')), 12 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 13 + ); 14 + 15 + -- Seed default label definitions (Bluesky-compatible) 16 + INSERT INTO label_definition (val, description, severity) VALUES 17 + ('!takedown', 'Content removed by moderators', 'takedown'), 18 + ('!suspend', 'Account suspended', 'takedown'), 19 + ('!warn', 'Show warning before displaying', 'alert'), 20 + ('!hide', 'Hide from feeds (still accessible via direct link)', 'alert'), 21 + ('porn', 'Pornographic content', 'alert'), 22 + ('sexual', 'Sexually suggestive content', 'alert'), 23 + ('nudity', 'Non-sexual nudity', 'alert'), 24 + ('gore', 'Graphic violence or gore', 'alert'), 25 + ('graphic-media', 'Disturbing or graphic media', 'alert'), 26 + ('impersonation', 'Account impersonating someone', 'inform'), 27 + ('spam', 'Spam or unwanted content', 'inform'); 28 + 29 + -- ============================================================================= 30 + -- Label Table 31 + -- ============================================================================= 32 + 33 + -- Applied labels on records/accounts 34 + CREATE TABLE IF NOT EXISTS label ( 35 + id INTEGER PRIMARY KEY AUTOINCREMENT, 36 + src TEXT NOT NULL, 37 + uri TEXT NOT NULL, 38 + cid TEXT, 39 + val TEXT NOT NULL, 40 + neg INTEGER NOT NULL DEFAULT 0, 41 + cts TEXT NOT NULL DEFAULT (datetime('now')), 42 + exp TEXT, 43 + FOREIGN KEY (val) REFERENCES label_definition(val) 44 + ); 45 + 46 + CREATE INDEX IF NOT EXISTS idx_label_uri ON label(uri); 47 + CREATE INDEX IF NOT EXISTS idx_label_val ON label(val); 48 + CREATE INDEX IF NOT EXISTS idx_label_src ON label(src); 49 + CREATE INDEX IF NOT EXISTS idx_label_cts ON label(cts DESC); 50 + -- Composite index for takedown queries (uri + val + neg) 51 + CREATE INDEX IF NOT EXISTS idx_label_takedown ON label(uri, val, neg); 52 + 53 + -- ============================================================================= 54 + -- Report Table 55 + -- ============================================================================= 56 + 57 + -- User-submitted reports awaiting review 58 + CREATE TABLE IF NOT EXISTS report ( 59 + id INTEGER PRIMARY KEY AUTOINCREMENT, 60 + reporter_did TEXT NOT NULL, 61 + subject_uri TEXT NOT NULL, 62 + reason_type TEXT NOT NULL CHECK (reason_type IN ('spam', 'violation', 'misleading', 'sexual', 'rude', 'other')), 63 + reason TEXT, 64 + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'resolved', 'dismissed')), 65 + resolved_by TEXT, 66 + resolved_at TEXT, 67 + created_at TEXT NOT NULL DEFAULT (datetime('now')), 68 + -- Prevent duplicate reports from same user for same content 69 + UNIQUE(reporter_did, subject_uri) 70 + ); 71 + 72 + CREATE INDEX IF NOT EXISTS idx_report_status ON report(status); 73 + CREATE INDEX IF NOT EXISTS idx_report_subject_uri ON report(subject_uri); 74 + CREATE INDEX IF NOT EXISTS idx_report_reporter_did ON report(reporter_did); 75 + CREATE INDEX IF NOT EXISTS idx_report_created_at ON report(created_at DESC); 76 + 77 + -- migrate:down 78 + 79 + DROP TABLE IF EXISTS report; 80 + DROP TABLE IF EXISTS label; 81 + DROP TABLE IF EXISTS label_definition;
+26
server/db/migrations/20251230000001_add_label_preferences.sql
··· 1 + -- migrate:up 2 + 3 + -- Add default_visibility to label_definition 4 + ALTER TABLE label_definition ADD COLUMN default_visibility TEXT NOT NULL DEFAULT 'warn'; 5 + 6 + -- Set appropriate defaults for specific labels 7 + -- porn should hide by default, while other content warnings should show a warning 8 + UPDATE label_definition SET default_visibility = 'hide' WHERE val = 'porn'; 9 + 10 + -- Create actor_label_preferences table 11 + CREATE TABLE IF NOT EXISTS actor_label_preference ( 12 + did TEXT NOT NULL, 13 + label_val TEXT NOT NULL, 14 + visibility TEXT NOT NULL, 15 + created_at TEXT NOT NULL DEFAULT (datetime('now')), 16 + PRIMARY KEY (did, label_val) 17 + ); 18 + 19 + -- Index for fast lookups by user 20 + CREATE INDEX IF NOT EXISTS idx_actor_label_preference_did ON actor_label_preference(did); 21 + 22 + -- migrate:down 23 + 24 + DROP INDEX IF EXISTS idx_actor_label_preference_did; 25 + DROP TABLE IF EXISTS actor_label_preference; 26 + -- Note: SQLite doesn't support DROP COLUMN, would need table recreation
+81
server/db/migrations_postgres/20251229000001_add_labels_and_reports.sql
··· 1 + -- migrate:up 2 + 3 + -- ============================================================================= 4 + -- Label Definition Table 5 + -- ============================================================================= 6 + 7 + -- Defines available label values for this instance 8 + CREATE TABLE IF NOT EXISTS label_definition ( 9 + val TEXT PRIMARY KEY NOT NULL, 10 + description TEXT NOT NULL, 11 + severity TEXT NOT NULL CHECK (severity IN ('inform', 'alert', 'takedown')), 12 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 13 + ); 14 + 15 + -- Seed default label definitions (Bluesky-compatible) 16 + INSERT INTO label_definition (val, description, severity) VALUES 17 + ('!takedown', 'Content removed by moderators', 'takedown'), 18 + ('!suspend', 'Account suspended', 'takedown'), 19 + ('!warn', 'Show warning before displaying', 'alert'), 20 + ('!hide', 'Hide from feeds (still accessible via direct link)', 'alert'), 21 + ('porn', 'Pornographic content', 'alert'), 22 + ('sexual', 'Sexually suggestive content', 'alert'), 23 + ('nudity', 'Non-sexual nudity', 'alert'), 24 + ('gore', 'Graphic violence or gore', 'alert'), 25 + ('graphic-media', 'Disturbing or graphic media', 'alert'), 26 + ('impersonation', 'Account impersonating someone', 'inform'), 27 + ('spam', 'Spam or unwanted content', 'inform'); 28 + 29 + -- ============================================================================= 30 + -- Label Table 31 + -- ============================================================================= 32 + 33 + -- Applied labels on records/accounts 34 + CREATE TABLE IF NOT EXISTS label ( 35 + id SERIAL PRIMARY KEY, 36 + src TEXT NOT NULL, 37 + uri TEXT NOT NULL, 38 + cid TEXT, 39 + val TEXT NOT NULL, 40 + neg BOOLEAN NOT NULL DEFAULT FALSE, 41 + cts TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 42 + exp TIMESTAMP WITH TIME ZONE, 43 + FOREIGN KEY (val) REFERENCES label_definition(val) 44 + ); 45 + 46 + CREATE INDEX IF NOT EXISTS idx_label_uri ON label(uri); 47 + CREATE INDEX IF NOT EXISTS idx_label_val ON label(val); 48 + CREATE INDEX IF NOT EXISTS idx_label_src ON label(src); 49 + CREATE INDEX IF NOT EXISTS idx_label_cts ON label(cts DESC); 50 + -- Composite index for takedown queries (uri + val + neg) 51 + CREATE INDEX IF NOT EXISTS idx_label_takedown ON label(uri, val, neg); 52 + 53 + -- ============================================================================= 54 + -- Report Table 55 + -- ============================================================================= 56 + 57 + -- User-submitted reports awaiting review 58 + CREATE TABLE IF NOT EXISTS report ( 59 + id SERIAL PRIMARY KEY, 60 + reporter_did TEXT NOT NULL, 61 + subject_uri TEXT NOT NULL, 62 + reason_type TEXT NOT NULL CHECK (reason_type IN ('spam', 'violation', 'misleading', 'sexual', 'rude', 'other')), 63 + reason TEXT, 64 + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'resolved', 'dismissed')), 65 + resolved_by TEXT, 66 + resolved_at TIMESTAMP WITH TIME ZONE, 67 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 68 + -- Prevent duplicate reports from same user for same content 69 + UNIQUE(reporter_did, subject_uri) 70 + ); 71 + 72 + CREATE INDEX IF NOT EXISTS idx_report_status ON report(status); 73 + CREATE INDEX IF NOT EXISTS idx_report_subject_uri ON report(subject_uri); 74 + CREATE INDEX IF NOT EXISTS idx_report_reporter_did ON report(reporter_did); 75 + CREATE INDEX IF NOT EXISTS idx_report_created_at ON report(created_at DESC); 76 + 77 + -- migrate:down 78 + 79 + DROP TABLE IF EXISTS report; 80 + DROP TABLE IF EXISTS label; 81 + DROP TABLE IF EXISTS label_definition;
+26
server/db/migrations_postgres/20251230000001_add_label_preferences.sql
··· 1 + -- migrate:up 2 + 3 + -- Add default_visibility to label_definition 4 + ALTER TABLE label_definition ADD COLUMN default_visibility TEXT NOT NULL DEFAULT 'warn'; 5 + 6 + -- Set appropriate defaults for specific labels 7 + -- porn should hide by default, while other content warnings should show a warning 8 + UPDATE label_definition SET default_visibility = 'hide' WHERE val = 'porn'; 9 + 10 + -- Create actor_label_preferences table 11 + CREATE TABLE IF NOT EXISTS actor_label_preference ( 12 + did TEXT NOT NULL, 13 + label_val TEXT NOT NULL, 14 + visibility TEXT NOT NULL, 15 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 16 + PRIMARY KEY (did, label_val) 17 + ); 18 + 19 + -- Index for fast lookups by user 20 + CREATE INDEX IF NOT EXISTS idx_actor_label_preference_did ON actor_label_preference(did); 21 + 22 + -- migrate:down 23 + 24 + DROP INDEX IF EXISTS idx_actor_label_preference_did; 25 + DROP TABLE IF EXISTS actor_label_preference; 26 + ALTER TABLE label_definition DROP COLUMN default_visibility;
+50 -1
server/db/schema.sql
··· 191 191 created_at INTEGER NOT NULL DEFAULT (unixepoch()) 192 192 ); 193 193 CREATE INDEX idx_admin_session_atp_session_id ON admin_session(atp_session_id); 194 + CREATE TABLE label_definition ( 195 + val TEXT PRIMARY KEY NOT NULL, 196 + description TEXT NOT NULL, 197 + severity TEXT NOT NULL CHECK (severity IN ('inform', 'alert', 'takedown')), 198 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 199 + , default_visibility TEXT NOT NULL DEFAULT 'warn'); 200 + CREATE TABLE label ( 201 + id INTEGER PRIMARY KEY AUTOINCREMENT, 202 + src TEXT NOT NULL, 203 + uri TEXT NOT NULL, 204 + cid TEXT, 205 + val TEXT NOT NULL, 206 + neg INTEGER NOT NULL DEFAULT 0, 207 + cts TEXT NOT NULL DEFAULT (datetime('now')), 208 + exp TEXT, 209 + FOREIGN KEY (val) REFERENCES label_definition(val) 210 + ); 211 + CREATE INDEX idx_label_uri ON label(uri); 212 + CREATE INDEX idx_label_val ON label(val); 213 + CREATE INDEX idx_label_src ON label(src); 214 + CREATE INDEX idx_label_cts ON label(cts DESC); 215 + CREATE INDEX idx_label_takedown ON label(uri, val, neg); 216 + CREATE TABLE report ( 217 + id INTEGER PRIMARY KEY AUTOINCREMENT, 218 + reporter_did TEXT NOT NULL, 219 + subject_uri TEXT NOT NULL, 220 + reason_type TEXT NOT NULL CHECK (reason_type IN ('spam', 'violation', 'misleading', 'sexual', 'rude', 'other')), 221 + reason TEXT, 222 + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'resolved', 'dismissed')), 223 + resolved_by TEXT, 224 + resolved_at TEXT, 225 + created_at TEXT NOT NULL DEFAULT (datetime('now')), 226 + -- Prevent duplicate reports from same user for same content 227 + UNIQUE(reporter_did, subject_uri) 228 + ); 229 + CREATE INDEX idx_report_status ON report(status); 230 + CREATE INDEX idx_report_subject_uri ON report(subject_uri); 231 + CREATE INDEX idx_report_reporter_did ON report(reporter_did); 232 + CREATE INDEX idx_report_created_at ON report(created_at DESC); 233 + CREATE TABLE actor_label_preference ( 234 + did TEXT NOT NULL, 235 + label_val TEXT NOT NULL, 236 + visibility TEXT NOT NULL, 237 + created_at TEXT NOT NULL DEFAULT (datetime('now')), 238 + PRIMARY KEY (did, label_val) 239 + ); 240 + CREATE INDEX idx_actor_label_preference_did ON actor_label_preference(did); 194 241 -- Dbmate schema migrations 195 242 INSERT INTO "schema_migrations" (version) VALUES 196 243 ('20241210000001'), 197 - ('20241227000001'); 244 + ('20241227000001'), 245 + ('20251229000001'), 246 + ('20251230000001');
+130
server/src/database/repositories/label_definitions.gleam
··· 1 + /// Repository for label definitions 2 + import database/executor.{type DbError, type Executor, Text} 3 + import gleam/dynamic/decode 4 + import gleam/list 5 + import gleam/option.{type Option, None, Some} 6 + import gleam/string 7 + 8 + /// Valid visibility values for label preferences 9 + pub const valid_visibilities = ["ignore", "show", "warn", "hide"] 10 + 11 + /// Validate a visibility value 12 + /// Returns Ok(visibility) if valid, Error with message if invalid 13 + pub fn validate_visibility(visibility: String) -> Result(String, String) { 14 + case list.contains(valid_visibilities, visibility) { 15 + True -> Ok(visibility) 16 + False -> 17 + Error( 18 + "Invalid visibility. Must be one of: " 19 + <> string.join(valid_visibilities, ", "), 20 + ) 21 + } 22 + } 23 + 24 + /// Label definition domain type 25 + pub type LabelDefinition { 26 + LabelDefinition( 27 + val: String, 28 + description: String, 29 + severity: String, 30 + default_visibility: String, 31 + created_at: String, 32 + ) 33 + } 34 + 35 + /// Get all label definitions 36 + pub fn get_all(exec: Executor) -> Result(List(LabelDefinition), DbError) { 37 + let sql = case executor.dialect(exec) { 38 + executor.SQLite -> 39 + "SELECT val, description, severity, default_visibility, created_at FROM label_definition ORDER BY val" 40 + executor.PostgreSQL -> 41 + "SELECT val, description, severity, default_visibility, created_at::text FROM label_definition ORDER BY val" 42 + } 43 + executor.query(exec, sql, [], label_definition_decoder()) 44 + } 45 + 46 + /// Get all non-system label definitions (excludes labels starting with !) 47 + pub fn get_non_system(exec: Executor) -> Result(List(LabelDefinition), DbError) { 48 + let sql = case executor.dialect(exec) { 49 + executor.SQLite -> 50 + "SELECT val, description, severity, default_visibility, created_at FROM label_definition WHERE val NOT LIKE '!%' ORDER BY val" 51 + executor.PostgreSQL -> 52 + "SELECT val, description, severity, default_visibility, created_at::text FROM label_definition WHERE val NOT LIKE '!%' ORDER BY val" 53 + } 54 + executor.query(exec, sql, [], label_definition_decoder()) 55 + } 56 + 57 + /// Get a label definition by value 58 + pub fn get( 59 + exec: Executor, 60 + val: String, 61 + ) -> Result(Option(LabelDefinition), DbError) { 62 + let sql = case executor.dialect(exec) { 63 + executor.SQLite -> 64 + "SELECT val, description, severity, default_visibility, created_at FROM label_definition WHERE val = " 65 + <> executor.placeholder(exec, 1) 66 + executor.PostgreSQL -> 67 + "SELECT val, description, severity, default_visibility, created_at::text FROM label_definition WHERE val = " 68 + <> executor.placeholder(exec, 1) 69 + } 70 + case executor.query(exec, sql, [Text(val)], label_definition_decoder()) { 71 + Ok([def]) -> Ok(Some(def)) 72 + Ok(_) -> Ok(None) 73 + Error(e) -> Error(e) 74 + } 75 + } 76 + 77 + /// Insert a new label definition 78 + pub fn insert( 79 + exec: Executor, 80 + val: String, 81 + description: String, 82 + severity: String, 83 + default_visibility: String, 84 + ) -> Result(Nil, DbError) { 85 + let p1 = executor.placeholder(exec, 1) 86 + let p2 = executor.placeholder(exec, 2) 87 + let p3 = executor.placeholder(exec, 3) 88 + let p4 = executor.placeholder(exec, 4) 89 + let sql = 90 + "INSERT INTO label_definition (val, description, severity, default_visibility) VALUES (" 91 + <> p1 92 + <> ", " 93 + <> p2 94 + <> ", " 95 + <> p3 96 + <> ", " 97 + <> p4 98 + <> ")" 99 + executor.exec(exec, sql, [ 100 + Text(val), 101 + Text(description), 102 + Text(severity), 103 + Text(default_visibility), 104 + ]) 105 + } 106 + 107 + /// Check if a label value exists 108 + pub fn exists(exec: Executor, val: String) -> Result(Bool, DbError) { 109 + case get(exec, val) { 110 + Ok(Some(_)) -> Ok(True) 111 + Ok(None) -> Ok(False) 112 + Error(e) -> Error(e) 113 + } 114 + } 115 + 116 + /// Decoder for LabelDefinition 117 + fn label_definition_decoder() -> decode.Decoder(LabelDefinition) { 118 + use val <- decode.field(0, decode.string) 119 + use description <- decode.field(1, decode.string) 120 + use severity <- decode.field(2, decode.string) 121 + use default_visibility <- decode.field(3, decode.string) 122 + use created_at <- decode.field(4, decode.string) 123 + decode.success(LabelDefinition( 124 + val:, 125 + description:, 126 + severity:, 127 + default_visibility:, 128 + created_at:, 129 + )) 130 + }
+137
server/src/database/repositories/label_preferences.gleam
··· 1 + /// Repository for actor label preferences 2 + import database/executor.{type DbError, type Executor, Text} 3 + import gleam/dynamic/decode 4 + import gleam/option.{type Option, None, Some} 5 + 6 + /// A label preference record 7 + pub type LabelPreference { 8 + LabelPreference( 9 + did: String, 10 + label_val: String, 11 + visibility: String, 12 + created_at: String, 13 + ) 14 + } 15 + 16 + /// Get all preferences for a user 17 + pub fn get_by_did( 18 + exec: Executor, 19 + did: String, 20 + ) -> Result(List(LabelPreference), DbError) { 21 + let p1 = executor.placeholder(exec, 1) 22 + let sql = case executor.dialect(exec) { 23 + executor.SQLite -> 24 + "SELECT did, label_val, visibility, created_at FROM actor_label_preference WHERE did = " 25 + <> p1 26 + executor.PostgreSQL -> 27 + "SELECT did, label_val, visibility, created_at::text FROM actor_label_preference WHERE did = " 28 + <> p1 29 + } 30 + 31 + executor.query(exec, sql, [Text(did)], preference_decoder()) 32 + } 33 + 34 + /// Get a specific preference 35 + pub fn get( 36 + exec: Executor, 37 + did: String, 38 + label_val: String, 39 + ) -> Result(Option(LabelPreference), DbError) { 40 + let p1 = executor.placeholder(exec, 1) 41 + let p2 = executor.placeholder(exec, 2) 42 + let sql = case executor.dialect(exec) { 43 + executor.SQLite -> 44 + "SELECT did, label_val, visibility, created_at FROM actor_label_preference WHERE did = " 45 + <> p1 46 + <> " AND label_val = " 47 + <> p2 48 + executor.PostgreSQL -> 49 + "SELECT did, label_val, visibility, created_at::text FROM actor_label_preference WHERE did = " 50 + <> p1 51 + <> " AND label_val = " 52 + <> p2 53 + } 54 + 55 + case 56 + executor.query( 57 + exec, 58 + sql, 59 + [Text(did), Text(label_val)], 60 + preference_decoder(), 61 + ) 62 + { 63 + Ok([pref]) -> Ok(Some(pref)) 64 + Ok(_) -> Ok(None) 65 + Error(e) -> Error(e) 66 + } 67 + } 68 + 69 + /// Set a preference (upsert) 70 + pub fn set( 71 + exec: Executor, 72 + did: String, 73 + label_val: String, 74 + visibility: String, 75 + ) -> Result(LabelPreference, DbError) { 76 + let p1 = executor.placeholder(exec, 1) 77 + let p2 = executor.placeholder(exec, 2) 78 + let p3 = executor.placeholder(exec, 3) 79 + 80 + let sql = case executor.dialect(exec) { 81 + executor.SQLite -> 82 + "INSERT INTO actor_label_preference (did, label_val, visibility) VALUES (" 83 + <> p1 84 + <> ", " 85 + <> p2 86 + <> ", " 87 + <> p3 88 + <> ") ON CONFLICT(did, label_val) DO UPDATE SET visibility = excluded.visibility RETURNING did, label_val, visibility, created_at" 89 + executor.PostgreSQL -> 90 + "INSERT INTO actor_label_preference (did, label_val, visibility) VALUES (" 91 + <> p1 92 + <> ", " 93 + <> p2 94 + <> ", " 95 + <> p3 96 + <> ") ON CONFLICT(did, label_val) DO UPDATE SET visibility = excluded.visibility RETURNING did, label_val, visibility, created_at::text" 97 + } 98 + 99 + case 100 + executor.query( 101 + exec, 102 + sql, 103 + [Text(did), Text(label_val), Text(visibility)], 104 + preference_decoder(), 105 + ) 106 + { 107 + Ok([pref]) -> Ok(pref) 108 + Ok(_) -> Error(executor.QueryError("Set did not return preference")) 109 + Error(e) -> Error(e) 110 + } 111 + } 112 + 113 + /// Delete a preference (reset to default) 114 + pub fn delete( 115 + exec: Executor, 116 + did: String, 117 + label_val: String, 118 + ) -> Result(Nil, DbError) { 119 + let p1 = executor.placeholder(exec, 1) 120 + let p2 = executor.placeholder(exec, 2) 121 + let sql = 122 + "DELETE FROM actor_label_preference WHERE did = " 123 + <> p1 124 + <> " AND label_val = " 125 + <> p2 126 + 127 + executor.exec(exec, sql, [Text(did), Text(label_val)]) 128 + } 129 + 130 + /// Decoder for LabelPreference 131 + fn preference_decoder() -> decode.Decoder(LabelPreference) { 132 + use did <- decode.field(0, decode.string) 133 + use label_val <- decode.field(1, decode.string) 134 + use visibility <- decode.field(2, decode.string) 135 + use created_at <- decode.field(3, decode.string) 136 + decode.success(LabelPreference(did:, label_val:, visibility:, created_at:)) 137 + }
+489
server/src/database/repositories/labels.gleam
··· 1 + /// Repository for labels 2 + import database/executor.{ 3 + type DbError, type Executor, type Value, Int, Null, Text, 4 + } 5 + import gleam/dynamic/decode 6 + import gleam/list 7 + import gleam/option.{type Option, None, Some} 8 + import gleam/string 9 + 10 + /// Validate that a URI is a valid AT Protocol subject URI 11 + /// Valid formats: 12 + /// - at://did:xxx/collection/rkey (record URI) 13 + /// - did:plc:xxx or did:web:xxx (account DID) 14 + pub fn is_valid_subject_uri(uri: String) -> Bool { 15 + case string.starts_with(uri, "at://") { 16 + True -> { 17 + // AT URI format: at://did/collection/rkey 18 + let without_prefix = string.drop_start(uri, 5) 19 + case string.split(without_prefix, "/") { 20 + [did, _collection, _rkey] -> string.starts_with(did, "did:") 21 + [did, _collection] -> string.starts_with(did, "did:") 22 + _ -> False 23 + } 24 + } 25 + False -> { 26 + // Allow bare DIDs for account labels 27 + string.starts_with(uri, "did:plc:") || string.starts_with(uri, "did:web:") 28 + } 29 + } 30 + } 31 + 32 + /// Label domain type 33 + pub type Label { 34 + Label( 35 + id: Int, 36 + src: String, 37 + uri: String, 38 + cid: Option(String), 39 + val: String, 40 + neg: Bool, 41 + cts: String, 42 + exp: Option(String), 43 + ) 44 + } 45 + 46 + /// Insert a new label 47 + pub fn insert( 48 + exec: Executor, 49 + src: String, 50 + uri: String, 51 + cid: Option(String), 52 + val: String, 53 + exp: Option(String), 54 + ) -> Result(Label, DbError) { 55 + let p1 = executor.placeholder(exec, 1) 56 + let p2 = executor.placeholder(exec, 2) 57 + let p3 = executor.placeholder(exec, 3) 58 + let p4 = executor.placeholder(exec, 4) 59 + let p5 = executor.placeholder(exec, 5) 60 + 61 + let cid_value = case cid { 62 + Some(c) -> Text(c) 63 + None -> Null 64 + } 65 + let exp_value = case exp { 66 + Some(e) -> Text(e) 67 + None -> Null 68 + } 69 + 70 + let sql = case executor.dialect(exec) { 71 + executor.SQLite -> 72 + "INSERT INTO label (src, uri, cid, val, exp) VALUES (" 73 + <> p1 74 + <> ", " 75 + <> p2 76 + <> ", " 77 + <> p3 78 + <> ", " 79 + <> p4 80 + <> ", " 81 + <> p5 82 + <> ") RETURNING id, src, uri, cid, val, neg, cts, exp" 83 + executor.PostgreSQL -> 84 + "INSERT INTO label (src, uri, cid, val, exp) VALUES (" 85 + <> p1 86 + <> ", " 87 + <> p2 88 + <> ", " 89 + <> p3 90 + <> ", " 91 + <> p4 92 + <> ", " 93 + <> p5 94 + <> ") RETURNING id, src, uri, cid, val, neg::int, cts::text, exp::text" 95 + } 96 + 97 + case 98 + executor.query( 99 + exec, 100 + sql, 101 + [Text(src), Text(uri), cid_value, Text(val), exp_value], 102 + label_decoder(), 103 + ) 104 + { 105 + Ok([label]) -> Ok(label) 106 + Ok(_) -> Error(executor.QueryError("Insert did not return label")) 107 + Error(e) -> Error(e) 108 + } 109 + } 110 + 111 + /// Insert a negation label (retraction) 112 + pub fn insert_negation( 113 + exec: Executor, 114 + src: String, 115 + uri: String, 116 + val: String, 117 + ) -> Result(Label, DbError) { 118 + let p1 = executor.placeholder(exec, 1) 119 + let p2 = executor.placeholder(exec, 2) 120 + let p3 = executor.placeholder(exec, 3) 121 + 122 + let sql = case executor.dialect(exec) { 123 + executor.SQLite -> 124 + "INSERT INTO label (src, uri, val, neg) VALUES (" 125 + <> p1 126 + <> ", " 127 + <> p2 128 + <> ", " 129 + <> p3 130 + <> ", 1) RETURNING id, src, uri, cid, val, neg, cts, exp" 131 + executor.PostgreSQL -> 132 + "INSERT INTO label (src, uri, val, neg) VALUES (" 133 + <> p1 134 + <> ", " 135 + <> p2 136 + <> ", " 137 + <> p3 138 + <> ", TRUE) RETURNING id, src, uri, cid, val, neg::int, cts::text, exp::text" 139 + } 140 + 141 + case 142 + executor.query( 143 + exec, 144 + sql, 145 + [Text(src), Text(uri), Text(val)], 146 + label_decoder(), 147 + ) 148 + { 149 + Ok([label]) -> Ok(label) 150 + Ok(_) -> Error(executor.QueryError("Insert did not return label")) 151 + Error(e) -> Error(e) 152 + } 153 + } 154 + 155 + /// Get labels for a list of URIs (batch fetch for GraphQL) 156 + /// Returns only active labels (non-negated, non-expired, and not retracted by a later negation) 157 + pub fn get_by_uris( 158 + exec: Executor, 159 + uris: List(String), 160 + ) -> Result(List(Label), DbError) { 161 + case uris { 162 + [] -> Ok([]) 163 + _ -> { 164 + let placeholders = executor.placeholders(exec, list.length(uris), 1) 165 + // Exclude labels that have been retracted by a subsequent negation label 166 + // Use (cts, id) for ordering to handle same-timestamp inserts 167 + let sql = case executor.dialect(exec) { 168 + executor.SQLite -> 169 + "SELECT l.id, l.src, l.uri, l.cid, l.val, l.neg, l.cts, l.exp FROM label l WHERE l.uri IN (" 170 + <> placeholders 171 + <> ") AND l.neg = 0 AND (l.exp IS NULL OR l.exp > datetime('now'))" 172 + <> " AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = l.uri AND n.val = l.val AND n.neg = 1 AND (n.cts > l.cts OR (n.cts = l.cts AND n.id > l.id)))" 173 + <> " ORDER BY l.cts DESC" 174 + executor.PostgreSQL -> 175 + "SELECT l.id, l.src, l.uri, l.cid, l.val, l.neg::int, l.cts::text, l.exp::text FROM label l WHERE l.uri IN (" 176 + <> placeholders 177 + <> ") AND l.neg = FALSE AND (l.exp IS NULL OR l.exp > NOW())" 178 + <> " AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = l.uri AND n.val = l.val AND n.neg = TRUE AND (n.cts > l.cts OR (n.cts = l.cts AND n.id > l.id)))" 179 + <> " ORDER BY l.cts DESC" 180 + } 181 + executor.query(exec, sql, list.map(uris, Text), label_decoder()) 182 + } 183 + } 184 + } 185 + 186 + /// Get all labels (admin query with optional filters) 187 + pub fn get_all( 188 + exec: Executor, 189 + uri_filter: Option(String), 190 + val_filter: Option(String), 191 + limit: Int, 192 + cursor: Option(Int), 193 + ) -> Result(List(Label), DbError) { 194 + let mut_where_parts: List(String) = [] 195 + let mut_params: List(Value) = [] 196 + let mut_param_count = 0 197 + 198 + // Add URI filter if provided 199 + let #(where_parts, params, param_count) = case uri_filter { 200 + Some(uri) -> { 201 + let p = executor.placeholder(exec, mut_param_count + 1) 202 + #( 203 + ["uri = " <> p, ..mut_where_parts], 204 + [Text(uri), ..mut_params], 205 + mut_param_count + 1, 206 + ) 207 + } 208 + None -> #(mut_where_parts, mut_params, mut_param_count) 209 + } 210 + 211 + // Add val filter if provided 212 + let #(where_parts2, params2, param_count2) = case val_filter { 213 + Some(v) -> { 214 + let p = executor.placeholder(exec, param_count + 1) 215 + #(["val = " <> p, ..where_parts], [Text(v), ..params], param_count + 1) 216 + } 217 + None -> #(where_parts, params, param_count) 218 + } 219 + 220 + // Add cursor filter if provided 221 + let #(where_parts3, params3, param_count3) = case cursor { 222 + Some(c) -> { 223 + let p = executor.placeholder(exec, param_count2 + 1) 224 + #(["id < " <> p, ..where_parts2], [Int(c), ..params2], param_count2 + 1) 225 + } 226 + None -> #(where_parts2, params2, param_count2) 227 + } 228 + 229 + let where_clause = case where_parts3 { 230 + [] -> "" 231 + parts -> " WHERE " <> string.join(list.reverse(parts), " AND ") 232 + } 233 + 234 + let limit_p = executor.placeholder(exec, param_count3 + 1) 235 + let sql = case executor.dialect(exec) { 236 + executor.SQLite -> 237 + "SELECT id, src, uri, cid, val, neg, cts, exp FROM label" 238 + <> where_clause 239 + <> " ORDER BY id DESC LIMIT " 240 + <> limit_p 241 + executor.PostgreSQL -> 242 + "SELECT id, src, uri, cid, val, neg::int, cts::text, exp::text FROM label" 243 + <> where_clause 244 + <> " ORDER BY id DESC LIMIT " 245 + <> limit_p 246 + } 247 + 248 + executor.query( 249 + exec, 250 + sql, 251 + list.append(list.reverse(params3), [Int(limit)]), 252 + label_decoder(), 253 + ) 254 + } 255 + 256 + /// Result type for paginated label queries 257 + pub type PaginatedLabels { 258 + PaginatedLabels(labels: List(Label), has_next_page: Bool, total_count: Int) 259 + } 260 + 261 + /// Get labels with connection-style pagination 262 + /// Returns labels, whether there's a next page, and total count 263 + pub fn get_paginated( 264 + exec: Executor, 265 + uri_filter: Option(String), 266 + val_filter: Option(String), 267 + first: Int, 268 + after_id: Option(Int), 269 + ) -> Result(PaginatedLabels, DbError) { 270 + // Fetch first + 1 to detect hasNextPage 271 + let fetch_limit = first + 1 272 + 273 + let mut_where_parts: List(String) = [] 274 + let mut_params: List(Value) = [] 275 + let mut_param_count = 0 276 + 277 + // Add URI filter if provided 278 + let #(where_parts, params, param_count) = case uri_filter { 279 + Some(uri) -> { 280 + let p = executor.placeholder(exec, mut_param_count + 1) 281 + #( 282 + ["uri = " <> p, ..mut_where_parts], 283 + [Text(uri), ..mut_params], 284 + mut_param_count + 1, 285 + ) 286 + } 287 + None -> #(mut_where_parts, mut_params, mut_param_count) 288 + } 289 + 290 + // Add val filter if provided 291 + let #(where_parts2, params2, param_count2) = case val_filter { 292 + Some(v) -> { 293 + let p = executor.placeholder(exec, param_count + 1) 294 + #(["val = " <> p, ..where_parts], [Text(v), ..params], param_count + 1) 295 + } 296 + None -> #(where_parts, params, param_count) 297 + } 298 + 299 + // Add cursor filter if provided (only for main query, not for count) 300 + let #(where_parts_with_cursor, params_with_cursor, param_count3) = case 301 + after_id 302 + { 303 + Some(c) -> { 304 + let p = executor.placeholder(exec, param_count2 + 1) 305 + #(["id < " <> p, ..where_parts2], [Int(c), ..params2], param_count2 + 1) 306 + } 307 + None -> #(where_parts2, params2, param_count2) 308 + } 309 + 310 + // Build WHERE clause for main query (with cursor) 311 + let where_clause = case where_parts_with_cursor { 312 + [] -> "" 313 + parts -> " WHERE " <> string.join(list.reverse(parts), " AND ") 314 + } 315 + 316 + // Build WHERE clause for count query (without cursor) 317 + let count_where_clause = case where_parts2 { 318 + [] -> "" 319 + parts -> " WHERE " <> string.join(list.reverse(parts), " AND ") 320 + } 321 + 322 + let limit_p = executor.placeholder(exec, param_count3 + 1) 323 + let main_sql = case executor.dialect(exec) { 324 + executor.SQLite -> 325 + "SELECT id, src, uri, cid, val, neg, cts, exp FROM label" 326 + <> where_clause 327 + <> " ORDER BY id DESC LIMIT " 328 + <> limit_p 329 + executor.PostgreSQL -> 330 + "SELECT id, src, uri, cid, val, neg::int, cts::text, exp::text FROM label" 331 + <> where_clause 332 + <> " ORDER BY id DESC LIMIT " 333 + <> limit_p 334 + } 335 + 336 + let count_sql = "SELECT COUNT(*) FROM label" <> count_where_clause 337 + 338 + // Execute main query 339 + let main_params = 340 + list.append(list.reverse(params_with_cursor), [Int(fetch_limit)]) 341 + case executor.query(exec, main_sql, main_params, label_decoder()) { 342 + Ok(labels_result) -> { 343 + // Execute count query (uses params without cursor) 344 + let count_params = list.reverse(params2) 345 + let count_decoder = { 346 + use count <- decode.field(0, decode.int) 347 + decode.success(count) 348 + } 349 + 350 + case executor.query(exec, count_sql, count_params, count_decoder) { 351 + Ok([total_count]) -> { 352 + // Determine if there's a next page 353 + let has_next = list.length(labels_result) > first 354 + let labels = case has_next { 355 + True -> list.take(labels_result, first) 356 + False -> labels_result 357 + } 358 + Ok(PaginatedLabels(labels:, has_next_page: has_next, total_count:)) 359 + } 360 + Ok(_) -> 361 + Ok(PaginatedLabels(labels: [], has_next_page: False, total_count: 0)) 362 + Error(e) -> Error(e) 363 + } 364 + } 365 + Error(e) -> Error(e) 366 + } 367 + } 368 + 369 + /// Check if a URI has an active takedown label (not retracted by negation) 370 + pub fn has_takedown(exec: Executor, uri: String) -> Result(Bool, DbError) { 371 + let p1 = executor.placeholder(exec, 1) 372 + let p2 = executor.placeholder(exec, 2) 373 + // Use (cts, id) for ordering to handle same-timestamp inserts 374 + let sql = case executor.dialect(exec) { 375 + executor.SQLite -> 376 + "SELECT 1 FROM label l WHERE l.uri = " 377 + <> p1 378 + <> " AND l.val IN ('!takedown', '!suspend') AND l.neg = 0 AND (l.exp IS NULL OR l.exp > datetime('now'))" 379 + <> " AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = " 380 + <> p2 381 + <> " AND n.val = l.val AND n.neg = 1 AND (n.cts > l.cts OR (n.cts = l.cts AND n.id > l.id)))" 382 + <> " LIMIT 1" 383 + executor.PostgreSQL -> 384 + "SELECT 1 FROM label l WHERE l.uri = " 385 + <> p1 386 + <> " AND l.val IN ('!takedown', '!suspend') AND l.neg = FALSE AND (l.exp IS NULL OR l.exp > NOW())" 387 + <> " AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = " 388 + <> p2 389 + <> " AND n.val = l.val AND n.neg = TRUE AND (n.cts > l.cts OR (n.cts = l.cts AND n.id > l.id)))" 390 + <> " LIMIT 1" 391 + } 392 + 393 + case executor.query(exec, sql, [Text(uri), Text(uri)], decode.dynamic) { 394 + Ok([_]) -> Ok(True) 395 + Ok([]) -> Ok(False) 396 + Ok(_) -> Ok(True) 397 + Error(e) -> Error(e) 398 + } 399 + } 400 + 401 + /// Batch check for takedown labels on multiple URIs 402 + /// Returns list of URIs that have active takedown labels (not retracted by negation) 403 + pub fn get_takedown_uris( 404 + exec: Executor, 405 + uris: List(String), 406 + ) -> Result(List(String), DbError) { 407 + case uris { 408 + [] -> Ok([]) 409 + _ -> { 410 + let placeholders = executor.placeholders(exec, list.length(uris), 1) 411 + // Exclude takedown labels that have been retracted by a subsequent negation 412 + // Use (cts, id) for ordering to handle same-timestamp inserts 413 + let sql = case executor.dialect(exec) { 414 + executor.SQLite -> 415 + "SELECT DISTINCT l.uri FROM label l WHERE l.uri IN (" 416 + <> placeholders 417 + <> ") AND l.val IN ('!takedown', '!suspend') AND l.neg = 0 AND (l.exp IS NULL OR l.exp > datetime('now'))" 418 + <> " AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = l.uri AND n.val = l.val AND n.neg = 1 AND (n.cts > l.cts OR (n.cts = l.cts AND n.id > l.id)))" 419 + executor.PostgreSQL -> 420 + "SELECT DISTINCT l.uri FROM label l WHERE l.uri IN (" 421 + <> placeholders 422 + <> ") AND l.val IN ('!takedown', '!suspend') AND l.neg = FALSE AND (l.exp IS NULL OR l.exp > NOW())" 423 + <> " AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = l.uri AND n.val = l.val AND n.neg = TRUE AND (n.cts > l.cts OR (n.cts = l.cts AND n.id > l.id)))" 424 + } 425 + 426 + let uri_decoder = { 427 + use uri <- decode.field(0, decode.string) 428 + decode.success(uri) 429 + } 430 + 431 + executor.query(exec, sql, list.map(uris, Text), uri_decoder) 432 + } 433 + } 434 + } 435 + 436 + /// Count active takedown labels for a collection (for accurate pagination counts) 437 + /// This counts records with !takedown or !suspend that haven't been retracted 438 + pub fn count_takedowns_for_collection( 439 + exec: Executor, 440 + collection: String, 441 + ) -> Result(Int, DbError) { 442 + let p1 = executor.placeholder(exec, 1) 443 + // Count distinct URIs with active takedown labels for records in this collection 444 + // Uses LIKE pattern matching on URI: at://did/collection/rkey 445 + // Use (cts, id) for ordering to handle same-timestamp inserts 446 + let sql = case executor.dialect(exec) { 447 + executor.SQLite -> 448 + "SELECT COUNT(DISTINCT l.uri) FROM label l " 449 + <> "WHERE l.uri LIKE '%/' || " 450 + <> p1 451 + <> " || '/%' " 452 + <> "AND l.val IN ('!takedown', '!suspend') AND l.neg = 0 " 453 + <> "AND (l.exp IS NULL OR l.exp > datetime('now')) " 454 + <> "AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = l.uri AND n.val = l.val AND n.neg = 1 AND (n.cts > l.cts OR (n.cts = l.cts AND n.id > l.id)))" 455 + executor.PostgreSQL -> 456 + "SELECT COUNT(DISTINCT l.uri) FROM label l " 457 + <> "WHERE l.uri LIKE '%/' || " 458 + <> p1 459 + <> " || '/%' " 460 + <> "AND l.val IN ('!takedown', '!suspend') AND l.neg = FALSE " 461 + <> "AND (l.exp IS NULL OR l.exp > NOW()) " 462 + <> "AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = l.uri AND n.val = l.val AND n.neg = TRUE AND (n.cts > l.cts OR (n.cts = l.cts AND n.id > l.id)))" 463 + } 464 + 465 + let count_decoder = { 466 + use count <- decode.field(0, decode.int) 467 + decode.success(count) 468 + } 469 + 470 + case executor.query(exec, sql, [Text(collection)], count_decoder) { 471 + Ok([count]) -> Ok(count) 472 + Ok(_) -> Ok(0) 473 + Error(e) -> Error(e) 474 + } 475 + } 476 + 477 + /// Decoder for Label 478 + fn label_decoder() -> decode.Decoder(Label) { 479 + use id <- decode.field(0, decode.int) 480 + use src <- decode.field(1, decode.string) 481 + use uri <- decode.field(2, decode.string) 482 + use cid <- decode.field(3, decode.optional(decode.string)) 483 + use val <- decode.field(4, decode.string) 484 + use neg_int <- decode.field(5, decode.int) 485 + use cts <- decode.field(6, decode.string) 486 + use exp <- decode.field(7, decode.optional(decode.string)) 487 + let neg = neg_int == 1 488 + decode.success(Label(id:, src:, uri:, cid:, val:, neg:, cts:, exp:)) 489 + }
+318
server/src/database/repositories/reports.gleam
··· 1 + /// Repository for moderation reports 2 + import database/executor.{ 3 + type DbError, type Executor, type Value, Int, Null, Text, 4 + } 5 + import gleam/dynamic/decode 6 + import gleam/list 7 + import gleam/option.{type Option, None, Some} 8 + import gleam/string 9 + 10 + /// Report domain type 11 + pub type Report { 12 + Report( 13 + id: Int, 14 + reporter_did: String, 15 + subject_uri: String, 16 + reason_type: String, 17 + reason: Option(String), 18 + status: String, 19 + resolved_by: Option(String), 20 + resolved_at: Option(String), 21 + created_at: String, 22 + ) 23 + } 24 + 25 + /// Insert a new report 26 + pub fn insert( 27 + exec: Executor, 28 + reporter_did: String, 29 + subject_uri: String, 30 + reason_type: String, 31 + reason: Option(String), 32 + ) -> Result(Report, DbError) { 33 + let p1 = executor.placeholder(exec, 1) 34 + let p2 = executor.placeholder(exec, 2) 35 + let p3 = executor.placeholder(exec, 3) 36 + let p4 = executor.placeholder(exec, 4) 37 + 38 + let reason_value = case reason { 39 + Some(r) -> Text(r) 40 + None -> Null 41 + } 42 + 43 + let sql = case executor.dialect(exec) { 44 + executor.SQLite -> 45 + "INSERT INTO report (reporter_did, subject_uri, reason_type, reason) VALUES (" 46 + <> p1 47 + <> ", " 48 + <> p2 49 + <> ", " 50 + <> p3 51 + <> ", " 52 + <> p4 53 + <> ") RETURNING id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at, created_at" 54 + executor.PostgreSQL -> 55 + "INSERT INTO report (reporter_did, subject_uri, reason_type, reason) VALUES (" 56 + <> p1 57 + <> ", " 58 + <> p2 59 + <> ", " 60 + <> p3 61 + <> ", " 62 + <> p4 63 + <> ") RETURNING id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at::text, created_at::text" 64 + } 65 + 66 + case 67 + executor.query( 68 + exec, 69 + sql, 70 + [Text(reporter_did), Text(subject_uri), Text(reason_type), reason_value], 71 + report_decoder(), 72 + ) 73 + { 74 + Ok([report]) -> Ok(report) 75 + Ok(_) -> Error(executor.QueryError("Insert did not return report")) 76 + Error(e) -> Error(e) 77 + } 78 + } 79 + 80 + /// Get a report by ID 81 + pub fn get(exec: Executor, id: Int) -> Result(Option(Report), DbError) { 82 + let sql = case executor.dialect(exec) { 83 + executor.SQLite -> 84 + "SELECT id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at, created_at FROM report WHERE id = " 85 + <> executor.placeholder(exec, 1) 86 + executor.PostgreSQL -> 87 + "SELECT id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at::text, created_at::text FROM report WHERE id = " 88 + <> executor.placeholder(exec, 1) 89 + } 90 + case executor.query(exec, sql, [Int(id)], report_decoder()) { 91 + Ok([report]) -> Ok(Some(report)) 92 + Ok(_) -> Ok(None) 93 + Error(e) -> Error(e) 94 + } 95 + } 96 + 97 + /// Get reports with optional status filter and pagination 98 + pub fn get_all( 99 + exec: Executor, 100 + status_filter: Option(String), 101 + limit: Int, 102 + cursor: Option(Int), 103 + ) -> Result(List(Report), DbError) { 104 + let mut_where_parts: List(String) = [] 105 + let mut_params: List(Value) = [] 106 + let mut_param_count = 0 107 + 108 + // Add status filter if provided 109 + let #(where_parts, params, param_count) = case status_filter { 110 + Some(s) -> { 111 + let p = executor.placeholder(exec, mut_param_count + 1) 112 + #(["status = " <> p], [Text(s)], mut_param_count + 1) 113 + } 114 + None -> #(mut_where_parts, mut_params, mut_param_count) 115 + } 116 + 117 + // Add cursor filter if provided 118 + let #(where_parts2, params2, param_count2) = case cursor { 119 + Some(c) -> { 120 + let p = executor.placeholder(exec, param_count + 1) 121 + #(["id < " <> p, ..where_parts], [Int(c), ..params], param_count + 1) 122 + } 123 + None -> #(where_parts, params, param_count) 124 + } 125 + 126 + let where_clause = case where_parts2 { 127 + [] -> "" 128 + parts -> " WHERE " <> string.join(list.reverse(parts), " AND ") 129 + } 130 + 131 + let limit_p = executor.placeholder(exec, param_count2 + 1) 132 + let sql = case executor.dialect(exec) { 133 + executor.SQLite -> 134 + "SELECT id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at, created_at FROM report" 135 + <> where_clause 136 + <> " ORDER BY id DESC LIMIT " 137 + <> limit_p 138 + executor.PostgreSQL -> 139 + "SELECT id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at::text, created_at::text FROM report" 140 + <> where_clause 141 + <> " ORDER BY id DESC LIMIT " 142 + <> limit_p 143 + } 144 + 145 + executor.query( 146 + exec, 147 + sql, 148 + list.append(list.reverse(params2), [Int(limit)]), 149 + report_decoder(), 150 + ) 151 + } 152 + 153 + /// Result type for paginated report queries 154 + pub type PaginatedReports { 155 + PaginatedReports(reports: List(Report), has_next_page: Bool, total_count: Int) 156 + } 157 + 158 + /// Get reports with connection-style pagination 159 + /// Returns reports, whether there's a next page, and total count 160 + pub fn get_paginated( 161 + exec: Executor, 162 + status_filter: Option(String), 163 + first: Int, 164 + after_id: Option(Int), 165 + ) -> Result(PaginatedReports, DbError) { 166 + // Fetch first + 1 to detect hasNextPage 167 + let fetch_limit = first + 1 168 + 169 + let mut_where_parts: List(String) = [] 170 + let mut_params: List(Value) = [] 171 + let mut_param_count = 0 172 + 173 + // Add status filter if provided 174 + let #(where_parts, params, param_count) = case status_filter { 175 + Some(s) -> { 176 + let p = executor.placeholder(exec, mut_param_count + 1) 177 + #(["status = " <> p], [Text(s)], mut_param_count + 1) 178 + } 179 + None -> #(mut_where_parts, mut_params, mut_param_count) 180 + } 181 + 182 + // Add cursor filter if provided (only for main query, not for count) 183 + let #(where_parts_with_cursor, params_with_cursor, param_count2) = case 184 + after_id 185 + { 186 + Some(c) -> { 187 + let p = executor.placeholder(exec, param_count + 1) 188 + #(["id < " <> p, ..where_parts], [Int(c), ..params], param_count + 1) 189 + } 190 + None -> #(where_parts, params, param_count) 191 + } 192 + 193 + // Build WHERE clause for main query (with cursor) 194 + let where_clause = case where_parts_with_cursor { 195 + [] -> "" 196 + parts -> " WHERE " <> string.join(list.reverse(parts), " AND ") 197 + } 198 + 199 + // Build WHERE clause for count query (without cursor) 200 + let count_where_clause = case where_parts { 201 + [] -> "" 202 + parts -> " WHERE " <> string.join(list.reverse(parts), " AND ") 203 + } 204 + 205 + let limit_p = executor.placeholder(exec, param_count2 + 1) 206 + let main_sql = case executor.dialect(exec) { 207 + executor.SQLite -> 208 + "SELECT id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at, created_at FROM report" 209 + <> where_clause 210 + <> " ORDER BY id DESC LIMIT " 211 + <> limit_p 212 + executor.PostgreSQL -> 213 + "SELECT id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at::text, created_at::text FROM report" 214 + <> where_clause 215 + <> " ORDER BY id DESC LIMIT " 216 + <> limit_p 217 + } 218 + 219 + let count_sql = "SELECT COUNT(*) FROM report" <> count_where_clause 220 + 221 + // Execute main query 222 + let main_params = 223 + list.append(list.reverse(params_with_cursor), [Int(fetch_limit)]) 224 + case executor.query(exec, main_sql, main_params, report_decoder()) { 225 + Ok(reports_result) -> { 226 + // Execute count query (uses params without cursor) 227 + let count_params = list.reverse(params) 228 + let count_decoder = { 229 + use count <- decode.field(0, decode.int) 230 + decode.success(count) 231 + } 232 + 233 + case executor.query(exec, count_sql, count_params, count_decoder) { 234 + Ok([total_count]) -> { 235 + // Determine if there's a next page 236 + let has_next = list.length(reports_result) > first 237 + let reports = case has_next { 238 + True -> list.take(reports_result, first) 239 + False -> reports_result 240 + } 241 + Ok(PaginatedReports(reports:, has_next_page: has_next, total_count:)) 242 + } 243 + Ok(_) -> 244 + Ok(PaginatedReports(reports: [], has_next_page: False, total_count: 0)) 245 + Error(e) -> Error(e) 246 + } 247 + } 248 + Error(e) -> Error(e) 249 + } 250 + } 251 + 252 + /// Resolve a report (apply label or dismiss) 253 + pub fn resolve( 254 + exec: Executor, 255 + id: Int, 256 + status: String, 257 + resolved_by: String, 258 + ) -> Result(Report, DbError) { 259 + let p1 = executor.placeholder(exec, 1) 260 + let p2 = executor.placeholder(exec, 2) 261 + let p3 = executor.placeholder(exec, 3) 262 + 263 + let sql = case executor.dialect(exec) { 264 + executor.SQLite -> 265 + "UPDATE report SET status = " 266 + <> p1 267 + <> ", resolved_by = " 268 + <> p2 269 + <> ", resolved_at = datetime('now') WHERE id = " 270 + <> p3 271 + <> " RETURNING id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at, created_at" 272 + executor.PostgreSQL -> 273 + "UPDATE report SET status = " 274 + <> p1 275 + <> ", resolved_by = " 276 + <> p2 277 + <> ", resolved_at = NOW() WHERE id = " 278 + <> p3 279 + <> " RETURNING id, reporter_did, subject_uri, reason_type, reason, status, resolved_by, resolved_at::text, created_at::text" 280 + } 281 + 282 + case 283 + executor.query( 284 + exec, 285 + sql, 286 + [Text(status), Text(resolved_by), Int(id)], 287 + report_decoder(), 288 + ) 289 + { 290 + Ok([report]) -> Ok(report) 291 + Ok(_) -> Error(executor.QueryError("Update did not return report")) 292 + Error(e) -> Error(e) 293 + } 294 + } 295 + 296 + /// Decoder for Report 297 + fn report_decoder() -> decode.Decoder(Report) { 298 + use id <- decode.field(0, decode.int) 299 + use reporter_did <- decode.field(1, decode.string) 300 + use subject_uri <- decode.field(2, decode.string) 301 + use reason_type <- decode.field(3, decode.string) 302 + use reason <- decode.field(4, decode.optional(decode.string)) 303 + use status <- decode.field(5, decode.string) 304 + use resolved_by <- decode.field(6, decode.optional(decode.string)) 305 + use resolved_at <- decode.field(7, decode.optional(decode.string)) 306 + use created_at <- decode.field(8, decode.string) 307 + decode.success(Report( 308 + id:, 309 + reporter_did:, 310 + subject_uri:, 311 + reason_type:, 312 + reason:, 313 + status:, 314 + resolved_by:, 315 + resolved_at:, 316 + created_at:, 317 + )) 318 + }
+70
server/src/graphql/admin/converters.gleam
··· 1 1 /// Value converters for admin GraphQL API 2 2 /// 3 3 /// Transform domain types to GraphQL value.Value objects 4 + import database/repositories/label_definitions 5 + import database/repositories/labels 6 + import database/repositories/reports 4 7 import database/types.{ 5 8 type ActivityBucket, type ActivityEntry, type Lexicon, type OAuthClient, 6 9 client_type_to_string, 7 10 } 8 11 import gleam/list 9 12 import gleam/option.{None, Some} 13 + import gleam/string 10 14 import swell/value 11 15 12 16 /// Convert CurrentSession data to GraphQL value ··· 115 119 #("createdAt", value.String(lexicon.created_at)), 116 120 ]) 117 121 } 122 + 123 + // ============================================================================= 124 + // Label and Report Converters 125 + // ============================================================================= 126 + 127 + /// Convert LabelDefinition to GraphQL value 128 + pub fn label_definition_to_value( 129 + def: label_definitions.LabelDefinition, 130 + ) -> value.Value { 131 + value.Object([ 132 + #("val", value.String(def.val)), 133 + #("description", value.String(def.description)), 134 + #("severity", value.Enum(string.uppercase(def.severity))), 135 + #("defaultVisibility", value.Enum(string.uppercase(def.default_visibility))), 136 + #("createdAt", value.String(def.created_at)), 137 + ]) 138 + } 139 + 140 + /// Convert Label to GraphQL value 141 + pub fn label_to_value(label: labels.Label) -> value.Value { 142 + let cid_value = case label.cid { 143 + Some(c) -> value.String(c) 144 + None -> value.Null 145 + } 146 + let exp_value = case label.exp { 147 + Some(e) -> value.String(e) 148 + None -> value.Null 149 + } 150 + value.Object([ 151 + #("id", value.Int(label.id)), 152 + #("src", value.String(label.src)), 153 + #("uri", value.String(label.uri)), 154 + #("cid", cid_value), 155 + #("val", value.String(label.val)), 156 + #("neg", value.Boolean(label.neg)), 157 + #("cts", value.String(label.cts)), 158 + #("exp", exp_value), 159 + ]) 160 + } 161 + 162 + /// Convert Report to GraphQL value 163 + pub fn report_to_value(report: reports.Report) -> value.Value { 164 + let reason_value = case report.reason { 165 + Some(r) -> value.String(r) 166 + None -> value.Null 167 + } 168 + let resolved_by_value = case report.resolved_by { 169 + Some(r) -> value.String(r) 170 + None -> value.Null 171 + } 172 + let resolved_at_value = case report.resolved_at { 173 + Some(r) -> value.String(r) 174 + None -> value.Null 175 + } 176 + value.Object([ 177 + #("id", value.Int(report.id)), 178 + #("reporterDid", value.String(report.reporter_did)), 179 + #("subjectUri", value.String(report.subject_uri)), 180 + #("reasonType", value.Enum(string.uppercase(report.reason_type))), 181 + #("reason", reason_value), 182 + #("status", value.Enum(string.uppercase(report.status))), 183 + #("resolvedBy", resolved_by_value), 184 + #("resolvedAt", resolved_at_value), 185 + #("createdAt", value.String(report.created_at)), 186 + ]) 187 + }
+36
server/src/graphql/admin/cursor.gleam
··· 1 + /// Cursor encoding/decoding for admin GraphQL connections 2 + /// 3 + /// Cursors are opaque base64-encoded strings in format "Type:ID" 4 + /// Example: "Label:42" -> "TGFiZWw6NDI=" 5 + import gleam/bit_array 6 + import gleam/int 7 + import gleam/result 8 + import gleam/string 9 + 10 + /// Encode a cursor from prefix and ID 11 + /// "Label", 42 -> "TGFiZWw6NDI=" 12 + pub fn encode(prefix: String, id: Int) -> String { 13 + let raw = prefix <> ":" <> int.to_string(id) 14 + bit_array.from_string(raw) 15 + |> bit_array.base64_encode(True) 16 + } 17 + 18 + /// Decode a cursor to prefix and ID 19 + /// "TGFiZWw6NDI=" -> Ok(#("Label", 42)) 20 + pub fn decode(cursor: String) -> Result(#(String, Int), Nil) { 21 + use decoded <- result.try( 22 + bit_array.base64_decode(cursor) 23 + |> result.try(fn(bits) { bit_array.to_string(bits) }) 24 + |> result.replace_error(Nil), 25 + ) 26 + 27 + case string.split(decoded, ":") { 28 + [prefix, id_str] -> { 29 + case int.parse(id_str) { 30 + Ok(id) -> Ok(#(prefix, id)) 31 + Error(_) -> Error(Nil) 32 + } 33 + } 34 + _ -> Error(Nil) 35 + } 36 + }
+351
server/src/graphql/admin/mutations.gleam
··· 6 6 import database/repositories/actors 7 7 import database/repositories/config as config_repo 8 8 import database/repositories/jetstream_activity 9 + import database/repositories/label_definitions 10 + import database/repositories/labels 9 11 import database/repositories/lexicons 10 12 import database/repositories/oauth_clients 11 13 import database/repositories/records 14 + import database/repositories/reports 12 15 import database/types 13 16 import gleam/erlang/process.{type Subject} 14 17 import gleam/list ··· 1098 1101 } 1099 1102 } 1100 1103 _ -> Error("Invalid clientId argument") 1104 + } 1105 + } 1106 + False -> Error("Admin privileges required") 1107 + } 1108 + } 1109 + Error(_) -> Error("Authentication required") 1110 + } 1111 + }, 1112 + ), 1113 + // createLabel mutation (admin only) 1114 + schema.field_with_args( 1115 + "createLabel", 1116 + schema.non_null(admin_types.label_type()), 1117 + "Create a label on a record or account (admin only)", 1118 + [ 1119 + schema.argument( 1120 + "uri", 1121 + schema.non_null(schema.string_type()), 1122 + "Subject URI (at:// or did:)", 1123 + None, 1124 + ), 1125 + schema.argument( 1126 + "val", 1127 + schema.non_null(schema.string_type()), 1128 + "Label value", 1129 + None, 1130 + ), 1131 + schema.argument( 1132 + "cid", 1133 + schema.string_type(), 1134 + "Optional CID for version-specific label", 1135 + None, 1136 + ), 1137 + schema.argument( 1138 + "exp", 1139 + schema.string_type(), 1140 + "Optional expiration datetime", 1141 + None, 1142 + ), 1143 + ], 1144 + fn(ctx) { 1145 + case session.get_current_session(req, conn, did_cache) { 1146 + Ok(sess) -> { 1147 + case config_repo.is_admin(conn, sess.did) { 1148 + True -> { 1149 + case 1150 + schema.get_argument(ctx, "uri"), 1151 + schema.get_argument(ctx, "val") 1152 + { 1153 + Some(value.String(uri)), Some(value.String(val)) -> { 1154 + // Validate URI format 1155 + case labels.is_valid_subject_uri(uri) { 1156 + False -> 1157 + Error( 1158 + "Invalid URI format. Must be at://did/collection/rkey or a DID", 1159 + ) 1160 + True -> { 1161 + // Validate label value exists 1162 + case label_definitions.exists(conn, val) { 1163 + Ok(True) -> { 1164 + let cid = case schema.get_argument(ctx, "cid") { 1165 + Some(value.String(c)) -> Some(c) 1166 + _ -> None 1167 + } 1168 + let exp = case schema.get_argument(ctx, "exp") { 1169 + Some(value.String(e)) -> Some(e) 1170 + _ -> None 1171 + } 1172 + case 1173 + labels.insert(conn, sess.did, uri, cid, val, exp) 1174 + { 1175 + Ok(label) -> Ok(converters.label_to_value(label)) 1176 + Error(_) -> Error("Failed to create label") 1177 + } 1178 + } 1179 + Ok(False) -> Error("Unknown label value: " <> val) 1180 + Error(_) -> Error("Failed to validate label value") 1181 + } 1182 + } 1183 + } 1184 + } 1185 + _, _ -> Error("uri and val are required") 1186 + } 1187 + } 1188 + False -> Error("Admin privileges required") 1189 + } 1190 + } 1191 + Error(_) -> Error("Authentication required") 1192 + } 1193 + }, 1194 + ), 1195 + // negateLabel mutation (admin only) 1196 + schema.field_with_args( 1197 + "negateLabel", 1198 + schema.non_null(admin_types.label_type()), 1199 + "Negate (retract) a label on a record or account (admin only)", 1200 + [ 1201 + schema.argument( 1202 + "uri", 1203 + schema.non_null(schema.string_type()), 1204 + "Subject URI", 1205 + None, 1206 + ), 1207 + schema.argument( 1208 + "val", 1209 + schema.non_null(schema.string_type()), 1210 + "Label value to negate", 1211 + None, 1212 + ), 1213 + ], 1214 + fn(ctx) { 1215 + case session.get_current_session(req, conn, did_cache) { 1216 + Ok(sess) -> { 1217 + case config_repo.is_admin(conn, sess.did) { 1218 + True -> { 1219 + case 1220 + schema.get_argument(ctx, "uri"), 1221 + schema.get_argument(ctx, "val") 1222 + { 1223 + Some(value.String(uri)), Some(value.String(val)) -> { 1224 + // Validate URI format 1225 + case labels.is_valid_subject_uri(uri) { 1226 + False -> 1227 + Error( 1228 + "Invalid URI format. Must be at://did/collection/rkey or a DID", 1229 + ) 1230 + True -> { 1231 + case labels.insert_negation(conn, sess.did, uri, val) { 1232 + Ok(label) -> Ok(converters.label_to_value(label)) 1233 + Error(_) -> Error("Failed to negate label") 1234 + } 1235 + } 1236 + } 1237 + } 1238 + _, _ -> Error("uri and val are required") 1239 + } 1240 + } 1241 + False -> Error("Admin privileges required") 1242 + } 1243 + } 1244 + Error(_) -> Error("Authentication required") 1245 + } 1246 + }, 1247 + ), 1248 + // createLabelDefinition mutation (admin only) 1249 + schema.field_with_args( 1250 + "createLabelDefinition", 1251 + schema.non_null(admin_types.label_definition_type()), 1252 + "Create a custom label definition (admin only)", 1253 + [ 1254 + schema.argument( 1255 + "val", 1256 + schema.non_null(schema.string_type()), 1257 + "Label value", 1258 + None, 1259 + ), 1260 + schema.argument( 1261 + "description", 1262 + schema.non_null(schema.string_type()), 1263 + "Description", 1264 + None, 1265 + ), 1266 + schema.argument( 1267 + "severity", 1268 + schema.non_null(admin_types.label_severity_enum()), 1269 + "Severity level", 1270 + None, 1271 + ), 1272 + schema.argument( 1273 + "defaultVisibility", 1274 + schema.string_type(), 1275 + "Default visibility setting (ignore, show, warn, hide). Defaults to warn.", 1276 + None, 1277 + ), 1278 + ], 1279 + fn(ctx) { 1280 + case session.get_current_session(req, conn, did_cache) { 1281 + Ok(sess) -> { 1282 + case config_repo.is_admin(conn, sess.did) { 1283 + True -> { 1284 + // Extract severity as string from either Enum or String 1285 + let severity_opt = case schema.get_argument(ctx, "severity") { 1286 + Some(value.Enum(s)) -> Some(string.lowercase(s)) 1287 + Some(value.String(s)) -> Some(string.lowercase(s)) 1288 + _ -> None 1289 + } 1290 + // Extract defaultVisibility (defaults to "warn") 1291 + let default_visibility = case 1292 + schema.get_argument(ctx, "defaultVisibility") 1293 + { 1294 + Some(value.Enum(v)) -> string.lowercase(v) 1295 + Some(value.String(v)) -> string.lowercase(v) 1296 + _ -> "warn" 1297 + } 1298 + // Validate defaultVisibility 1299 + case label_definitions.validate_visibility(default_visibility) { 1300 + Error(e) -> Error(e) 1301 + Ok(_) -> { 1302 + case 1303 + schema.get_argument(ctx, "val"), 1304 + schema.get_argument(ctx, "description"), 1305 + severity_opt 1306 + { 1307 + Some(value.String(val)), 1308 + Some(value.String(desc)), 1309 + Some(severity) 1310 + -> { 1311 + case 1312 + label_definitions.insert( 1313 + conn, 1314 + val, 1315 + desc, 1316 + severity, 1317 + default_visibility, 1318 + ) 1319 + { 1320 + Ok(_) -> { 1321 + case label_definitions.get(conn, val) { 1322 + Ok(Some(def)) -> 1323 + Ok(converters.label_definition_to_value(def)) 1324 + _ -> Error("Failed to fetch created definition") 1325 + } 1326 + } 1327 + Error(_) -> Error("Failed to create label definition") 1328 + } 1329 + } 1330 + _, _, _ -> 1331 + Error("val, description, and severity are required") 1332 + } 1333 + } 1334 + } 1335 + } 1336 + False -> Error("Admin privileges required") 1337 + } 1338 + } 1339 + Error(_) -> Error("Authentication required") 1340 + } 1341 + }, 1342 + ), 1343 + // resolveReport mutation (admin only) 1344 + schema.field_with_args( 1345 + "resolveReport", 1346 + schema.non_null(admin_types.report_type()), 1347 + "Resolve a moderation report (admin only)", 1348 + [ 1349 + schema.argument( 1350 + "id", 1351 + schema.non_null(schema.int_type()), 1352 + "Report ID", 1353 + None, 1354 + ), 1355 + schema.argument( 1356 + "action", 1357 + schema.non_null(admin_types.report_action_enum()), 1358 + "Action to take", 1359 + None, 1360 + ), 1361 + schema.argument( 1362 + "labelVal", 1363 + schema.string_type(), 1364 + "Label value to apply (required if action is APPLY_LABEL)", 1365 + None, 1366 + ), 1367 + ], 1368 + fn(ctx) { 1369 + case session.get_current_session(req, conn, did_cache) { 1370 + Ok(sess) -> { 1371 + case config_repo.is_admin(conn, sess.did) { 1372 + True -> { 1373 + // Extract action as string from either Enum or String 1374 + let action_opt = case schema.get_argument(ctx, "action") { 1375 + Some(value.Enum(a)) -> Some(a) 1376 + Some(value.String(a)) -> Some(a) 1377 + _ -> None 1378 + } 1379 + case schema.get_argument(ctx, "id"), action_opt { 1380 + Some(value.Int(id)), Some(action) -> { 1381 + // Get the report first 1382 + case reports.get(conn, id) { 1383 + Ok(Some(report)) -> { 1384 + case action { 1385 + "APPLY_LABEL" -> { 1386 + case schema.get_argument(ctx, "labelVal") { 1387 + Some(value.String(label_val)) -> { 1388 + // Validate label value exists 1389 + case label_definitions.exists(conn, label_val) { 1390 + Ok(True) -> { 1391 + // Create the label 1392 + case 1393 + labels.insert( 1394 + conn, 1395 + sess.did, 1396 + report.subject_uri, 1397 + None, 1398 + label_val, 1399 + None, 1400 + ) 1401 + { 1402 + Ok(_) -> { 1403 + // Mark report as resolved 1404 + case 1405 + reports.resolve( 1406 + conn, 1407 + id, 1408 + "resolved", 1409 + sess.did, 1410 + ) 1411 + { 1412 + Ok(resolved) -> 1413 + Ok(converters.report_to_value( 1414 + resolved, 1415 + )) 1416 + Error(_) -> 1417 + Error("Failed to resolve report") 1418 + } 1419 + } 1420 + Error(_) -> Error("Failed to apply label") 1421 + } 1422 + } 1423 + Ok(False) -> 1424 + Error("Unknown label value: " <> label_val) 1425 + Error(_) -> 1426 + Error("Failed to validate label value") 1427 + } 1428 + } 1429 + _ -> 1430 + Error( 1431 + "labelVal is required when action is APPLY_LABEL", 1432 + ) 1433 + } 1434 + } 1435 + "DISMISS" -> { 1436 + case 1437 + reports.resolve(conn, id, "dismissed", sess.did) 1438 + { 1439 + Ok(resolved) -> 1440 + Ok(converters.report_to_value(resolved)) 1441 + Error(_) -> Error("Failed to dismiss report") 1442 + } 1443 + } 1444 + _ -> Error("Invalid action") 1445 + } 1446 + } 1447 + Ok(None) -> Error("Report not found") 1448 + Error(_) -> Error("Failed to fetch report") 1449 + } 1450 + } 1451 + _, _ -> Error("id and action are required") 1101 1452 } 1102 1453 } 1103 1454 False -> Error("Admin privileges required")
+286
server/src/graphql/admin/queries.gleam
··· 5 5 import database/repositories/actors 6 6 import database/repositories/config as config_repo 7 7 import database/repositories/jetstream_activity 8 + import database/repositories/label_definitions 9 + import database/repositories/label_preferences 10 + import database/repositories/labels 8 11 import database/repositories/lexicons 9 12 import database/repositories/oauth_clients 10 13 import database/repositories/records 14 + import database/repositories/reports 11 15 import gleam/erlang/process.{type Subject} 12 16 import gleam/list 13 17 import gleam/option.{None, Some} 14 18 import gleam/otp/actor 19 + import gleam/string 15 20 import graphql/admin/converters 21 + import graphql/admin/cursor 16 22 import graphql/admin/types as admin_types 23 + import graphql/lexicon/converters as lexicon_converters 17 24 import lib/oauth/did_cache 25 + import swell/connection 18 26 import swell/schema 19 27 import swell/value 20 28 import wisp ··· 230 238 } 231 239 } 232 240 _ -> Error("Invalid or missing hours argument") 241 + } 242 + }, 243 + ), 244 + // labelDefinitions query 245 + schema.field( 246 + "labelDefinitions", 247 + schema.non_null( 248 + schema.list_type(schema.non_null(admin_types.label_definition_type())), 249 + ), 250 + "Get all label definitions", 251 + fn(_ctx) { 252 + case label_definitions.get_all(conn) { 253 + Ok(defs) -> 254 + Ok(value.List(list.map(defs, converters.label_definition_to_value))) 255 + Error(_) -> Error("Failed to fetch label definitions") 256 + } 257 + }, 258 + ), 259 + // viewerLabelPreferences query (authenticated users) 260 + schema.field( 261 + "viewerLabelPreferences", 262 + schema.non_null( 263 + schema.list_type(schema.non_null(admin_types.label_preference_type())), 264 + ), 265 + "Get label preferences for the current user (non-system labels only)", 266 + fn(_ctx) { 267 + case session.get_current_session(req, conn, did_cache) { 268 + Ok(sess) -> { 269 + // Get non-system label definitions 270 + case label_definitions.get_non_system(conn) { 271 + Ok(defs) -> { 272 + // Get user's preferences 273 + case label_preferences.get_by_did(conn, sess.did) { 274 + Ok(prefs) -> { 275 + // Build a map of label_val -> visibility 276 + let pref_map = 277 + list.fold(prefs, [], fn(acc, pref) { 278 + [#(pref.label_val, pref.visibility), ..acc] 279 + }) 280 + 281 + // Map each definition to a preference, using user's setting or default 282 + let result = 283 + list.map(defs, fn(def) { 284 + let visibility = case list.key_find(pref_map, def.val) { 285 + Ok(v) -> v 286 + Error(_) -> def.default_visibility 287 + } 288 + lexicon_converters.label_preference_to_value( 289 + def, 290 + visibility, 291 + ) 292 + }) 293 + 294 + Ok(value.List(result)) 295 + } 296 + Error(_) -> Error("Failed to fetch label preferences") 297 + } 298 + } 299 + Error(_) -> Error("Failed to fetch label definitions") 300 + } 301 + } 302 + Error(_) -> Error("Authentication required") 303 + } 304 + }, 305 + ), 306 + // labels query (admin only) - Connection type 307 + schema.field_with_args( 308 + "labels", 309 + schema.non_null(admin_types.label_connection_type()), 310 + "Get labels with optional filters (admin only)", 311 + [ 312 + schema.argument( 313 + "uri", 314 + schema.string_type(), 315 + "Filter by subject URI", 316 + None, 317 + ), 318 + schema.argument( 319 + "val", 320 + schema.string_type(), 321 + "Filter by label value", 322 + None, 323 + ), 324 + schema.argument( 325 + "first", 326 + schema.int_type(), 327 + "Number of items to fetch (default 50)", 328 + None, 329 + ), 330 + schema.argument( 331 + "after", 332 + schema.string_type(), 333 + "Cursor for pagination", 334 + None, 335 + ), 336 + ], 337 + fn(ctx) { 338 + case session.get_current_session(req, conn, did_cache) { 339 + Ok(sess) -> { 340 + case config_repo.is_admin(conn, sess.did) { 341 + True -> { 342 + let uri_filter = case schema.get_argument(ctx, "uri") { 343 + Some(value.String(u)) -> Some(u) 344 + _ -> None 345 + } 346 + let val_filter = case schema.get_argument(ctx, "val") { 347 + Some(value.String(v)) -> Some(v) 348 + _ -> None 349 + } 350 + let first = case schema.get_argument(ctx, "first") { 351 + Some(value.Int(f)) -> f 352 + _ -> 50 353 + } 354 + let after_id = case schema.get_argument(ctx, "after") { 355 + Some(value.String(c)) -> { 356 + case cursor.decode(c) { 357 + Ok(#("Label", id)) -> Some(id) 358 + _ -> None 359 + } 360 + } 361 + _ -> None 362 + } 363 + 364 + case 365 + labels.get_paginated( 366 + conn, 367 + uri_filter, 368 + val_filter, 369 + first, 370 + after_id, 371 + ) 372 + { 373 + Ok(paginated) -> { 374 + // Build edges with cursors 375 + let edges = 376 + list.map(paginated.labels, fn(label) { 377 + connection.Edge( 378 + node: converters.label_to_value(label), 379 + cursor: cursor.encode("Label", label.id), 380 + ) 381 + }) 382 + 383 + // Build page info 384 + let start_cursor = case list.first(paginated.labels) { 385 + Ok(first_label) -> 386 + Some(cursor.encode("Label", first_label.id)) 387 + Error(_) -> None 388 + } 389 + let end_cursor = case list.last(paginated.labels) { 390 + Ok(last_label) -> 391 + Some(cursor.encode("Label", last_label.id)) 392 + Error(_) -> None 393 + } 394 + 395 + let page_info = 396 + connection.PageInfo( 397 + has_next_page: paginated.has_next_page, 398 + has_previous_page: option.is_some(after_id), 399 + start_cursor: start_cursor, 400 + end_cursor: end_cursor, 401 + ) 402 + 403 + let conn_value = 404 + connection.Connection( 405 + edges: edges, 406 + page_info: page_info, 407 + total_count: Some(paginated.total_count), 408 + ) 409 + 410 + Ok(connection.connection_to_value(conn_value)) 411 + } 412 + Error(_) -> Error("Failed to fetch labels") 413 + } 414 + } 415 + False -> Error("Admin privileges required") 416 + } 417 + } 418 + Error(_) -> Error("Authentication required") 419 + } 420 + }, 421 + ), 422 + // reports query (admin only) - Connection type 423 + schema.field_with_args( 424 + "reports", 425 + schema.non_null(admin_types.report_connection_type()), 426 + "Get moderation reports with optional status filter (admin only)", 427 + [ 428 + schema.argument( 429 + "status", 430 + admin_types.report_status_enum(), 431 + "Filter by status", 432 + None, 433 + ), 434 + schema.argument( 435 + "first", 436 + schema.int_type(), 437 + "Number of items to fetch (default 50)", 438 + None, 439 + ), 440 + schema.argument( 441 + "after", 442 + schema.string_type(), 443 + "Cursor for pagination", 444 + None, 445 + ), 446 + ], 447 + fn(ctx) { 448 + case session.get_current_session(req, conn, did_cache) { 449 + Ok(sess) -> { 450 + case config_repo.is_admin(conn, sess.did) { 451 + True -> { 452 + let status_filter = case schema.get_argument(ctx, "status") { 453 + Some(value.Enum(s)) -> Some(string.lowercase(s)) 454 + _ -> None 455 + } 456 + let first = case schema.get_argument(ctx, "first") { 457 + Some(value.Int(f)) -> f 458 + _ -> 50 459 + } 460 + let after_id = case schema.get_argument(ctx, "after") { 461 + Some(value.String(c)) -> { 462 + case cursor.decode(c) { 463 + Ok(#("Report", id)) -> Some(id) 464 + _ -> None 465 + } 466 + } 467 + _ -> None 468 + } 469 + 470 + case 471 + reports.get_paginated(conn, status_filter, first, after_id) 472 + { 473 + Ok(paginated) -> { 474 + // Build edges with cursors 475 + let edges = 476 + list.map(paginated.reports, fn(report) { 477 + connection.Edge( 478 + node: converters.report_to_value(report), 479 + cursor: cursor.encode("Report", report.id), 480 + ) 481 + }) 482 + 483 + // Build page info 484 + let start_cursor = case list.first(paginated.reports) { 485 + Ok(first_report) -> 486 + Some(cursor.encode("Report", first_report.id)) 487 + Error(_) -> None 488 + } 489 + let end_cursor = case list.last(paginated.reports) { 490 + Ok(last_report) -> 491 + Some(cursor.encode("Report", last_report.id)) 492 + Error(_) -> None 493 + } 494 + 495 + let page_info = 496 + connection.PageInfo( 497 + has_next_page: paginated.has_next_page, 498 + has_previous_page: option.is_some(after_id), 499 + start_cursor: start_cursor, 500 + end_cursor: end_cursor, 501 + ) 502 + 503 + let conn_value = 504 + connection.Connection( 505 + edges: edges, 506 + page_info: page_info, 507 + total_count: Some(paginated.total_count), 508 + ) 509 + 510 + Ok(connection.connection_to_value(conn_value)) 511 + } 512 + Error(_) -> Error("Failed to fetch reports") 513 + } 514 + } 515 + False -> Error("Admin privileges required") 516 + } 517 + } 518 + Error(_) -> Error("Authentication required") 233 519 } 234 520 }, 235 521 ),
+253
server/src/graphql/admin/types.gleam
··· 3 3 /// Contains all object types, enum types, and the get_field helper 4 4 import gleam/list 5 5 import gleam/option.{Some} 6 + import swell/connection 6 7 import swell/schema 7 8 import swell/value 8 9 ··· 304 305 }), 305 306 ]) 306 307 } 308 + 309 + // ============================================================================= 310 + // Label and Report Types 311 + // ============================================================================= 312 + 313 + /// LabelSeverity enum for label definitions 314 + pub fn label_severity_enum() -> schema.Type { 315 + schema.enum_type("LabelSeverity", "Severity level of a label", [ 316 + schema.enum_value("INFORM", "Informational, client can show indicator"), 317 + schema.enum_value("ALERT", "Client should warn/blur"), 318 + schema.enum_value("TAKEDOWN", "Server filters, content not returned"), 319 + ]) 320 + } 321 + 322 + /// LabelVisibility enum for user preferences 323 + pub fn label_visibility_enum() -> schema.Type { 324 + schema.enum_type("LabelVisibility", "How to display labeled content", [ 325 + schema.enum_value("IGNORE", "Show content normally, no indicator"), 326 + schema.enum_value("SHOW", "Explicitly show (for adult content)"), 327 + schema.enum_value("WARN", "Blur with click-through warning"), 328 + schema.enum_value("HIDE", "Do not show content"), 329 + ]) 330 + } 331 + 332 + /// LabelDefinition type 333 + pub fn label_definition_type() -> schema.Type { 334 + schema.object_type("LabelDefinition", "Label value definition", [ 335 + schema.field( 336 + "val", 337 + schema.non_null(schema.string_type()), 338 + "Label value (e.g., 'porn', '!takedown')", 339 + fn(ctx) { Ok(get_field(ctx, "val")) }, 340 + ), 341 + schema.field( 342 + "description", 343 + schema.non_null(schema.string_type()), 344 + "Human-readable description", 345 + fn(ctx) { Ok(get_field(ctx, "description")) }, 346 + ), 347 + schema.field( 348 + "severity", 349 + schema.non_null(label_severity_enum()), 350 + "Severity level", 351 + fn(ctx) { Ok(get_field(ctx, "severity")) }, 352 + ), 353 + schema.field( 354 + "defaultVisibility", 355 + schema.non_null(label_visibility_enum()), 356 + "Default visibility setting for this label", 357 + fn(ctx) { Ok(get_field(ctx, "defaultVisibility")) }, 358 + ), 359 + schema.field( 360 + "createdAt", 361 + schema.non_null(schema.string_type()), 362 + "Creation timestamp", 363 + fn(ctx) { Ok(get_field(ctx, "createdAt")) }, 364 + ), 365 + ]) 366 + } 367 + 368 + /// LabelPreference type for viewerLabelPreferences query 369 + pub fn label_preference_type() -> schema.Type { 370 + schema.object_type("LabelPreference", "User preference for a label type", [ 371 + schema.field( 372 + "val", 373 + schema.non_null(schema.string_type()), 374 + "Label value", 375 + fn(ctx) { Ok(get_field(ctx, "val")) }, 376 + ), 377 + schema.field( 378 + "description", 379 + schema.non_null(schema.string_type()), 380 + "Label description", 381 + fn(ctx) { Ok(get_field(ctx, "description")) }, 382 + ), 383 + schema.field( 384 + "severity", 385 + schema.non_null(label_severity_enum()), 386 + "Label severity (inform, alert, takedown)", 387 + fn(ctx) { Ok(get_field(ctx, "severity")) }, 388 + ), 389 + schema.field( 390 + "defaultVisibility", 391 + schema.non_null(label_visibility_enum()), 392 + "Default visibility setting", 393 + fn(ctx) { Ok(get_field(ctx, "defaultVisibility")) }, 394 + ), 395 + schema.field( 396 + "visibility", 397 + schema.non_null(label_visibility_enum()), 398 + "User's effective visibility setting", 399 + fn(ctx) { Ok(get_field(ctx, "visibility")) }, 400 + ), 401 + ]) 402 + } 403 + 404 + /// Label type 405 + pub fn label_type() -> schema.Type { 406 + schema.object_type("Label", "Applied label on a record or account", [ 407 + schema.field("id", schema.non_null(schema.int_type()), "Label ID", fn(ctx) { 408 + Ok(get_field(ctx, "id")) 409 + }), 410 + schema.field( 411 + "src", 412 + schema.non_null(schema.string_type()), 413 + "DID of admin who applied the label", 414 + fn(ctx) { Ok(get_field(ctx, "src")) }, 415 + ), 416 + schema.field( 417 + "uri", 418 + schema.non_null(schema.string_type()), 419 + "Subject URI (at:// or did:)", 420 + fn(ctx) { Ok(get_field(ctx, "uri")) }, 421 + ), 422 + schema.field( 423 + "cid", 424 + schema.string_type(), 425 + "Optional CID for version-specific label", 426 + fn(ctx) { Ok(get_field(ctx, "cid")) }, 427 + ), 428 + schema.field( 429 + "val", 430 + schema.non_null(schema.string_type()), 431 + "Label value", 432 + fn(ctx) { Ok(get_field(ctx, "val")) }, 433 + ), 434 + schema.field( 435 + "neg", 436 + schema.non_null(schema.boolean_type()), 437 + "True if this is a negation (retraction)", 438 + fn(ctx) { Ok(get_field(ctx, "neg")) }, 439 + ), 440 + schema.field( 441 + "cts", 442 + schema.non_null(schema.string_type()), 443 + "Creation timestamp", 444 + fn(ctx) { Ok(get_field(ctx, "cts")) }, 445 + ), 446 + schema.field( 447 + "exp", 448 + schema.string_type(), 449 + "Optional expiration timestamp", 450 + fn(ctx) { Ok(get_field(ctx, "exp")) }, 451 + ), 452 + ]) 453 + } 454 + 455 + /// Edge type for Label connection 456 + pub fn label_edge_type() -> schema.Type { 457 + connection.edge_type("Label", label_type()) 458 + } 459 + 460 + /// Connection type for paginated Label results 461 + pub fn label_connection_type() -> schema.Type { 462 + connection.connection_type("Label", label_edge_type()) 463 + } 464 + 465 + /// ReportReasonType enum 466 + pub fn report_reason_type_enum() -> schema.Type { 467 + schema.enum_type("ReportReasonType", "Reason for submitting a report", [ 468 + schema.enum_value("SPAM", "Spam or unwanted content"), 469 + schema.enum_value("VIOLATION", "Violates terms of service"), 470 + schema.enum_value("MISLEADING", "Misleading or false information"), 471 + schema.enum_value("SEXUAL", "Inappropriate sexual content"), 472 + schema.enum_value("RUDE", "Rude or abusive behavior"), 473 + schema.enum_value("OTHER", "Other reason"), 474 + ]) 475 + } 476 + 477 + /// ReportStatus enum 478 + pub fn report_status_enum() -> schema.Type { 479 + schema.enum_type("ReportStatus", "Status of a moderation report", [ 480 + schema.enum_value("PENDING", "Awaiting review"), 481 + schema.enum_value("RESOLVED", "Resolved with action"), 482 + schema.enum_value("DISMISSED", "Dismissed without action"), 483 + ]) 484 + } 485 + 486 + /// ReportAction enum for resolving reports 487 + pub fn report_action_enum() -> schema.Type { 488 + schema.enum_type("ReportAction", "Action to take when resolving a report", [ 489 + schema.enum_value("APPLY_LABEL", "Apply a label to the subject"), 490 + schema.enum_value("DISMISS", "Dismiss the report without action"), 491 + ]) 492 + } 493 + 494 + /// Report type 495 + pub fn report_type() -> schema.Type { 496 + schema.object_type("Report", "User-submitted moderation report", [ 497 + schema.field("id", schema.non_null(schema.int_type()), "Report ID", fn(ctx) { 498 + Ok(get_field(ctx, "id")) 499 + }), 500 + schema.field( 501 + "reporterDid", 502 + schema.non_null(schema.string_type()), 503 + "DID of reporter", 504 + fn(ctx) { Ok(get_field(ctx, "reporterDid")) }, 505 + ), 506 + schema.field( 507 + "subjectUri", 508 + schema.non_null(schema.string_type()), 509 + "Subject URI (at:// or did:)", 510 + fn(ctx) { Ok(get_field(ctx, "subjectUri")) }, 511 + ), 512 + schema.field( 513 + "reasonType", 514 + schema.non_null(report_reason_type_enum()), 515 + "Reason type", 516 + fn(ctx) { Ok(get_field(ctx, "reasonType")) }, 517 + ), 518 + schema.field( 519 + "reason", 520 + schema.string_type(), 521 + "Optional free-text explanation", 522 + fn(ctx) { Ok(get_field(ctx, "reason")) }, 523 + ), 524 + schema.field( 525 + "status", 526 + schema.non_null(report_status_enum()), 527 + "Report status", 528 + fn(ctx) { Ok(get_field(ctx, "status")) }, 529 + ), 530 + schema.field( 531 + "resolvedBy", 532 + schema.string_type(), 533 + "DID of admin who resolved", 534 + fn(ctx) { Ok(get_field(ctx, "resolvedBy")) }, 535 + ), 536 + schema.field( 537 + "resolvedAt", 538 + schema.string_type(), 539 + "Resolution timestamp", 540 + fn(ctx) { Ok(get_field(ctx, "resolvedAt")) }, 541 + ), 542 + schema.field( 543 + "createdAt", 544 + schema.non_null(schema.string_type()), 545 + "Creation timestamp", 546 + fn(ctx) { Ok(get_field(ctx, "createdAt")) }, 547 + ), 548 + ]) 549 + } 550 + 551 + /// Edge type for Report connection 552 + pub fn report_edge_type() -> schema.Type { 553 + connection.edge_type("Report", report_type()) 554 + } 555 + 556 + /// Connection type for paginated Report results 557 + pub fn report_connection_type() -> schema.Type { 558 + connection.connection_type("Report", report_edge_type()) 559 + }
+17
server/src/graphql/lexicon/converters.gleam
··· 3 3 /// Transform database records and dynamic values to GraphQL value.Value objects 4 4 import database/executor.{type Executor} 5 5 import database/repositories/actors 6 + import database/repositories/label_definitions 6 7 import database/types 7 8 import gleam/dict 8 9 import gleam/dynamic 9 10 import gleam/dynamic/decode 10 11 import gleam/json 11 12 import gleam/list 13 + import gleam/string 12 14 import swell/value 13 15 14 16 /// Convert a database Record to a GraphQL value.Value ··· 165 167 _ -> Error(Nil) 166 168 } 167 169 } 170 + 171 + /// Convert a LabelPreference to GraphQL value 172 + /// Takes a label definition and the user's effective visibility setting 173 + pub fn label_preference_to_value( 174 + def: label_definitions.LabelDefinition, 175 + visibility: String, 176 + ) -> value.Value { 177 + value.Object([ 178 + #("val", value.String(def.val)), 179 + #("description", value.String(def.description)), 180 + #("severity", value.Enum(string.uppercase(def.severity))), 181 + #("defaultVisibility", value.Enum(string.uppercase(def.default_visibility))), 182 + #("visibility", value.Enum(string.uppercase(visibility))), 183 + ]) 184 + }
+164 -9
server/src/graphql/lexicon/fetchers.gleam
··· 7 7 import database/queries/aggregates 8 8 import database/queries/pagination 9 9 import database/repositories/actors 10 + import database/repositories/labels 10 11 import database/repositories/records 11 12 import database/types 12 13 import gleam/dict 14 + import gleam/dynamic/decode 15 + import gleam/int 16 + import gleam/json 13 17 import gleam/list 14 18 import gleam/option 15 19 import gleam/result ··· 21 25 import lexicon_graphql/schema/database 22 26 import swell/value 23 27 28 + /// Filter out records with active takedown labels 29 + fn filter_takedowns( 30 + db: Executor, 31 + record_list: List(types.Record), 32 + ) -> List(types.Record) { 33 + let record_uris = list.map(record_list, fn(r) { r.uri }) 34 + let takedown_uris = case labels.get_takedown_uris(db, record_uris) { 35 + Ok(uris) -> uris 36 + Error(_) -> [] 37 + } 38 + list.filter(record_list, fn(record) { 39 + !list.contains(takedown_uris, record.uri) 40 + }) 41 + } 42 + 43 + /// Parse self-labels from a record's JSON if present 44 + /// Self-labels have format: {"$type": "com.atproto.label.defs#selfLabels", "values": [{"val": "..."}]} 45 + fn parse_self_labels_from_json( 46 + json_str: String, 47 + uri: String, 48 + ) -> List(value.Value) { 49 + // Decoder for self-labels structure 50 + let self_labels_decoder = { 51 + use type_field <- decode.field("$type", decode.string) 52 + use values <- decode.field( 53 + "values", 54 + decode.list({ 55 + use val <- decode.field("val", decode.string) 56 + decode.success(val) 57 + }), 58 + ) 59 + decode.success(#(type_field, values)) 60 + } 61 + 62 + let labels_field_decoder = { 63 + use labels <- decode.field("labels", self_labels_decoder) 64 + decode.success(labels) 65 + } 66 + 67 + case json.parse(json_str, labels_field_decoder) { 68 + Error(_) -> [] 69 + Ok(#(type_field, vals)) -> { 70 + case type_field == "com.atproto.label.defs#selfLabels" { 71 + False -> [] 72 + True -> { 73 + // Extract DID from URI (at://did:plc:xxx/...) 74 + let src = case string.split(uri, "/") { 75 + ["at:", "", did, ..] -> did 76 + _ -> "" 77 + } 78 + list.map(vals, fn(val) { 79 + value.Object([ 80 + #("val", value.String(val)), 81 + #("src", value.String(src)), 82 + #("uri", value.String(uri)), 83 + #("neg", value.Boolean(False)), 84 + #("cts", value.Null), 85 + #("exp", value.Null), 86 + #("cid", value.Null), 87 + #("id", value.Null), 88 + ]) 89 + }) 90 + } 91 + } 92 + } 93 + } 94 + } 95 + 24 96 /// Create a record fetcher for paginated collection queries 25 97 pub fn record_fetcher(db: Executor) { 26 98 fn(collection_nsid: String, pagination_params: dataloader.PaginationParams) -> Result( ··· 41 113 } 42 114 43 115 // Get total count for this collection (with where filter if present) 44 - let total_count = 116 + // Subtract takedown count for accurate pagination 117 + let raw_count = 45 118 records.get_collection_count_with_where(db, collection_nsid, where_clause) 46 - |> result.map(option.Some) 47 - |> result.unwrap(option.None) 119 + |> result.unwrap(0) 120 + let takedown_count = 121 + labels.count_takedowns_for_collection(db, collection_nsid) 122 + |> result.unwrap(0) 123 + let total_count = option.Some(int.max(0, raw_count - takedown_count)) 48 124 49 125 // Fetch records from database for this collection with pagination 50 126 case ··· 62 138 Error(_) -> Ok(#([], option.None, False, False, option.None)) 63 139 // Return empty result on error 64 140 Ok(#(record_list, next_cursor, has_next_page, has_previous_page)) -> { 141 + // Filter out records with takedown labels 142 + let filtered_records = filter_takedowns(db, record_list) 143 + 65 144 // Convert database records to GraphQL values with cursors 66 145 let graphql_records_with_cursors = 67 - list.map(record_list, fn(record) { 146 + list.map(filtered_records, fn(record) { 68 147 let graphql_value = converters.record_to_graphql_value(record, db) 69 148 // Generate cursor for this record 70 149 let record_cursor = ··· 104 183 // DID join: fetch records by DID and collection 105 184 case records.get_by_dids_and_collection(db, uris, collection) { 106 185 Ok(record_list) -> { 186 + // Filter out records with takedown labels 187 + let filtered_records = filter_takedowns(db, record_list) 188 + 107 189 // Group records by DID 108 190 let grouped = 109 - list.fold(record_list, dict.new(), fn(acc, record) { 191 + list.fold(filtered_records, dict.new(), fn(acc, record) { 110 192 let graphql_value = 111 193 converters.record_to_graphql_value(record, db) 112 194 let existing = ··· 122 204 // Forward join: fetch records by their URIs 123 205 case records.get_by_uris(db, uris) { 124 206 Ok(record_list) -> { 207 + // Filter out records with takedown labels 208 + let filtered_records = filter_takedowns(db, record_list) 209 + 125 210 // Group records by URI 126 211 let grouped = 127 - list.fold(record_list, dict.new(), fn(acc, record) { 212 + list.fold(filtered_records, dict.new(), fn(acc, record) { 128 213 let graphql_value = 129 214 converters.record_to_graphql_value(record, db) 130 215 // For forward joins, return single record per URI ··· 145 230 records.get_by_reference_field(db, collection, reference_field, uris) 146 231 { 147 232 Ok(record_list) -> { 233 + // Filter out records with takedown labels 234 + let filtered_records = filter_takedowns(db, record_list) 235 + 148 236 // Group records by the parent URI they reference 149 237 // Parse each record's JSON to extract the reference field value 150 238 let grouped = 151 - list.fold(record_list, dict.new(), fn(acc, record) { 239 + list.fold(filtered_records, dict.new(), fn(acc, record) { 152 240 let graphql_value = 153 241 converters.record_to_graphql_value(record, db) 154 242 // Extract the reference field from the record JSON to find parent URI ··· 221 309 has_previous_page, 222 310 total_count, 223 311 )) -> { 312 + // Filter out records with takedown labels 313 + let filtered_records = filter_takedowns(db, record_list) 314 + 224 315 // Convert records to GraphQL values with cursors 225 316 let edges = 226 - list.map(record_list, fn(record) { 317 + list.map(filtered_records, fn(record) { 227 318 let graphql_value = 228 319 converters.record_to_graphql_value(record, db) 229 320 let cursor = ··· 264 355 has_previous_page, 265 356 total_count, 266 357 )) -> { 358 + // Filter out records with takedown labels 359 + let filtered_records = filter_takedowns(db, record_list) 360 + 267 361 // Convert records to GraphQL values with cursors 268 362 let edges = 269 - list.map(record_list, fn(record) { 363 + list.map(filtered_records, fn(record) { 270 364 let graphql_value = 271 365 converters.record_to_graphql_value(record, db) 272 366 let cursor = ··· 425 519 Ok(#(converted, end_cursor, has_next, has_prev)) 426 520 } 427 521 } 522 + 523 + /// Create a labels fetcher for batch loading labels by URI 524 + /// Now accepts tuples of (uri, optional_record_json) to support self-labels 525 + pub fn labels_fetcher(db: Executor) { 526 + fn(uris_with_json: List(#(String, option.Option(String)))) -> Result( 527 + dict.Dict(String, List(value.Value)), 528 + String, 529 + ) { 530 + let uris = list.map(uris_with_json, fn(pair) { pair.0 }) 531 + 532 + case labels.get_by_uris(db, uris) { 533 + Ok(label_list) -> { 534 + // Group moderator labels by URI 535 + let mod_labels = 536 + list.fold(label_list, dict.new(), fn(acc, label) { 537 + let label_value = 538 + value.Object([ 539 + #("id", value.Int(label.id)), 540 + #("src", value.String(label.src)), 541 + #("uri", value.String(label.uri)), 542 + #("cid", case label.cid { 543 + option.Some(c) -> value.String(c) 544 + option.None -> value.Null 545 + }), 546 + #("val", value.String(label.val)), 547 + #("neg", value.Boolean(label.neg)), 548 + #("cts", value.String(label.cts)), 549 + #("exp", case label.exp { 550 + option.Some(e) -> value.String(e) 551 + option.None -> value.Null 552 + }), 553 + ]) 554 + let existing = dict.get(acc, label.uri) |> result.unwrap([]) 555 + dict.insert(acc, label.uri, [label_value, ..existing]) 556 + }) 557 + 558 + // Merge with self-labels from record JSON 559 + let merged = 560 + list.fold(uris_with_json, mod_labels, fn(acc, pair) { 561 + let #(uri, json_opt) = pair 562 + case json_opt { 563 + option.None -> acc 564 + option.Some(json_str) -> { 565 + let self_labels = parse_self_labels_from_json(json_str, uri) 566 + case self_labels { 567 + [] -> acc 568 + _ -> { 569 + let existing = dict.get(acc, uri) |> result.unwrap([]) 570 + dict.insert(acc, uri, list.append(self_labels, existing)) 571 + } 572 + } 573 + } 574 + } 575 + }) 576 + 577 + Ok(merged) 578 + } 579 + Error(_) -> Error("Failed to fetch labels") 580 + } 581 + } 582 + }
+458 -3
server/src/graphql/lexicon/mutations.gleam
··· 6 6 import atproto_auth 7 7 import backfill 8 8 import database/executor.{type Executor} 9 + import database/repositories/label_definitions 10 + import database/repositories/label_preferences 9 11 import database/repositories/lexicons 10 12 import database/repositories/records 13 + import database/repositories/reports 11 14 import dpop 12 15 import gleam/dict 13 16 import gleam/dynamic ··· 18 21 import gleam/list 19 22 import gleam/option 20 23 import gleam/result 24 + import gleam/string 21 25 import honk 22 26 import honk/errors 27 + import lexicon_graphql/input/union as union_input 23 28 import lib/oauth/did_cache 24 29 import pubsub 25 30 import swell/schema ··· 39 44 ) 40 45 } 41 46 42 - // ─── Private Auth Helper ─────────────────────────────────────────── 47 + // ─── Private Auth Helpers ─────────────────────────────────────────── 43 48 44 49 /// Authenticated session info returned by auth helper 45 50 type AuthenticatedSession { ··· 47 52 user_info: atproto_auth.UserInfo, 48 53 session: atproto_auth.AtprotoSession, 49 54 ) 55 + } 56 + 57 + /// Lightweight auth that only verifies the token 58 + /// Use this for mutations that don't need ATP session (e.g., label preferences) 59 + fn get_viewer_auth( 60 + resolver_ctx: schema.Context, 61 + db: executor.Executor, 62 + ) -> Result(atproto_auth.UserInfo, String) { 63 + // Extract auth token from context data 64 + let token = case resolver_ctx.data { 65 + option.Some(value.Object(fields)) -> { 66 + case list.key_find(fields, "auth_token") { 67 + Ok(value.String(t)) -> Ok(t) 68 + Ok(_) -> Error("auth_token must be a string") 69 + Error(_) -> 70 + Error("Authentication required. Please provide Authorization header.") 71 + } 72 + } 73 + _ -> Error("Authentication required. Please provide Authorization header.") 74 + } 75 + 76 + use token <- result.try(token) 77 + 78 + // Verify OAuth token 79 + atproto_auth.verify_token(db, token) 80 + |> result.map_error(fn(err) { 81 + case err { 82 + atproto_auth.UnauthorizedToken -> "Unauthorized" 83 + atproto_auth.TokenExpired -> "Token expired" 84 + atproto_auth.MissingAuthHeader -> "Missing authentication" 85 + atproto_auth.InvalidAuthHeader -> "Invalid authentication header" 86 + _ -> "Authentication error" 87 + } 88 + }) 50 89 } 51 90 52 91 /// Extract token, verify auth, ensure actor exists, get ATP session ··· 346 385 } 347 386 } 348 387 388 + // ─── Private Union Helpers ──────────────────────────────────────── 389 + 390 + /// Union field info: path to field and list of possible type refs 391 + type UnionFieldInfo { 392 + UnionFieldInfo(path: List(String), refs: List(String)) 393 + } 394 + 395 + /// Get union field info from a lexicon for a given collection 396 + fn get_union_fields( 397 + collection: String, 398 + lexicons: List(json.Json), 399 + ) -> List(UnionFieldInfo) { 400 + let lexicon = 401 + list.find(lexicons, fn(lex) { 402 + case json.parse(json.to_string(lex), decode.at(["id"], decode.string)) { 403 + Ok(id) -> id == collection 404 + Error(_) -> False 405 + } 406 + }) 407 + 408 + case lexicon { 409 + Ok(lex) -> { 410 + let properties_decoder = 411 + decode.at( 412 + ["defs", "main", "record", "properties"], 413 + decode.dict(decode.string, decode.dynamic), 414 + ) 415 + case json.parse(json.to_string(lex), properties_decoder) { 416 + Ok(properties) -> extract_union_fields_from_properties(properties, []) 417 + Error(_) -> [] 418 + } 419 + } 420 + Error(_) -> [] 421 + } 422 + } 423 + 424 + /// Recursively extract union fields from lexicon properties 425 + fn extract_union_fields_from_properties( 426 + properties: dict.Dict(String, dynamic.Dynamic), 427 + current_path: List(String), 428 + ) -> List(UnionFieldInfo) { 429 + dict.fold(properties, [], fn(acc, field_name, field_def) { 430 + let field_path = list.append(current_path, [field_name]) 431 + let type_result = decode.run(field_def, decode.at(["type"], decode.string)) 432 + 433 + case type_result { 434 + Ok("union") -> { 435 + // Extract refs from the union definition 436 + let refs_result = 437 + decode.run(field_def, decode.at(["refs"], decode.list(decode.string))) 438 + case refs_result { 439 + Ok(refs) -> [UnionFieldInfo(path: field_path, refs: refs), ..acc] 440 + Error(_) -> acc 441 + } 442 + } 443 + Ok("object") -> { 444 + let nested_props_result = 445 + decode.run( 446 + field_def, 447 + decode.at( 448 + ["properties"], 449 + decode.dict(decode.string, decode.dynamic), 450 + ), 451 + ) 452 + case nested_props_result { 453 + Ok(nested_props) -> { 454 + let nested_fields = 455 + extract_union_fields_from_properties(nested_props, field_path) 456 + list.append(nested_fields, acc) 457 + } 458 + Error(_) -> acc 459 + } 460 + } 461 + Ok("array") -> { 462 + let items_type_result = 463 + decode.run(field_def, decode.at(["items", "type"], decode.string)) 464 + case items_type_result { 465 + Ok("union") -> { 466 + let refs_result = 467 + decode.run( 468 + field_def, 469 + decode.at(["items", "refs"], decode.list(decode.string)), 470 + ) 471 + case refs_result { 472 + Ok(refs) -> [UnionFieldInfo(path: field_path, refs: refs), ..acc] 473 + Error(_) -> acc 474 + } 475 + } 476 + Ok("object") -> { 477 + let item_props_result = 478 + decode.run( 479 + field_def, 480 + decode.at( 481 + ["items", "properties"], 482 + decode.dict(decode.string, decode.dynamic), 483 + ), 484 + ) 485 + case item_props_result { 486 + Ok(item_props) -> { 487 + let nested_fields = 488 + extract_union_fields_from_properties(item_props, field_path) 489 + list.append(nested_fields, acc) 490 + } 491 + Error(_) -> acc 492 + } 493 + } 494 + _ -> acc 495 + } 496 + } 497 + _ -> acc 498 + } 499 + }) 500 + } 501 + 502 + /// Transform union inputs by adding $type based on the discriminator 503 + fn transform_union_inputs( 504 + input: value.Value, 505 + union_fields: List(UnionFieldInfo), 506 + ) -> value.Value { 507 + transform_unions_at_paths(input, union_fields, []) 508 + } 509 + 510 + /// Recursively transform union values at specified paths 511 + fn transform_unions_at_paths( 512 + val: value.Value, 513 + union_fields: List(UnionFieldInfo), 514 + current_path: List(String), 515 + ) -> value.Value { 516 + case val { 517 + value.Object(fields) -> { 518 + // Check if current path matches a union field 519 + let matching_union = 520 + list.find(union_fields, fn(uf) { uf.path == current_path }) 521 + 522 + case matching_union { 523 + Ok(union_info) -> transform_union_object(fields, union_info.refs) 524 + Error(_) -> { 525 + // Recurse into object fields 526 + value.Object( 527 + list.map(fields, fn(field) { 528 + let #(key, field_val) = field 529 + let new_path = list.append(current_path, [key]) 530 + #( 531 + key, 532 + transform_unions_at_paths(field_val, union_fields, new_path), 533 + ) 534 + }), 535 + ) 536 + } 537 + } 538 + } 539 + value.List(items) -> { 540 + // Check if current path is a union array 541 + let matching_union = 542 + list.find(union_fields, fn(uf) { uf.path == current_path }) 543 + 544 + case matching_union { 545 + Ok(union_info) -> { 546 + // Transform each item in the array 547 + value.List( 548 + list.map(items, fn(item) { 549 + case item { 550 + value.Object(item_fields) -> 551 + transform_union_object(item_fields, union_info.refs) 552 + _ -> item 553 + } 554 + }), 555 + ) 556 + } 557 + Error(_) -> { 558 + // Recurse into list items 559 + value.List( 560 + list.map(items, fn(item) { 561 + transform_unions_at_paths(item, union_fields, current_path) 562 + }), 563 + ) 564 + } 565 + } 566 + } 567 + _ -> val 568 + } 569 + } 570 + 571 + /// Transform a union object from GraphQL discriminated format to AT Protocol format 572 + /// GraphQL input: { type: "SELF_LABELS", selfLabels: { values: [...] } } 573 + /// AT Protocol output: { $type: "com.atproto.label.defs#selfLabels", values: [...] } 574 + fn transform_union_object( 575 + fields: List(#(String, value.Value)), 576 + refs: List(String), 577 + ) -> value.Value { 578 + // Find the "type" discriminator field 579 + let type_field = list.key_find(fields, "type") 580 + 581 + case type_field { 582 + Ok(value.Enum(enum_value)) -> { 583 + // Convert enum value back to ref 584 + let matching_ref = find_ref_for_enum_value(enum_value, refs) 585 + case matching_ref { 586 + Ok(ref) -> { 587 + // Find the variant field (same name as the short ref name) 588 + let short_name = enum_value_to_short_name(enum_value) 589 + case list.key_find(fields, short_name) { 590 + Ok(value.Object(variant_fields)) -> { 591 + // Build AT Protocol format: variant fields + $type 592 + value.Object([#("$type", value.String(ref)), ..variant_fields]) 593 + } 594 + _ -> { 595 + // No variant data, just return $type 596 + value.Object([#("$type", value.String(ref))]) 597 + } 598 + } 599 + } 600 + Error(_) -> value.Object(fields) 601 + } 602 + } 603 + Ok(value.String(str_value)) -> { 604 + // Handle string type discriminator (fallback) 605 + let matching_ref = find_ref_for_enum_value(str_value, refs) 606 + case matching_ref { 607 + Ok(ref) -> { 608 + let short_name = enum_value_to_short_name(str_value) 609 + case list.key_find(fields, short_name) { 610 + Ok(value.Object(variant_fields)) -> { 611 + value.Object([#("$type", value.String(ref)), ..variant_fields]) 612 + } 613 + _ -> value.Object([#("$type", value.String(ref))]) 614 + } 615 + } 616 + Error(_) -> value.Object(fields) 617 + } 618 + } 619 + _ -> value.Object(fields) 620 + } 621 + } 622 + 623 + /// Find the ref that matches an enum value 624 + /// "SELF_LABELS" matches "com.atproto.label.defs#selfLabels" 625 + fn find_ref_for_enum_value( 626 + enum_value: String, 627 + refs: List(String), 628 + ) -> Result(String, Nil) { 629 + list.find(refs, fn(ref) { union_input.ref_to_enum_value(ref) == enum_value }) 630 + } 631 + 632 + /// Convert SCREAMING_SNAKE_CASE to camelCase for field lookup 633 + /// "SELF_LABELS" -> "selfLabels" 634 + fn enum_value_to_short_name(enum_value: String) -> String { 635 + union_input.screaming_snake_to_camel(enum_value) 636 + } 637 + 349 638 /// Decode base64 string to bit array 350 639 fn decode_base64(base64_str: String) -> Result(BitArray, Nil) { 351 640 Ok(do_erlang_base64_decode(base64_str)) ··· 429 718 430 719 // Transform blob inputs from GraphQL format to AT Protocol format 431 720 let blob_paths = get_blob_paths(collection, all_lex_jsons) 432 - let transformed_input = transform_blob_inputs(input, blob_paths) 721 + let blob_transformed = transform_blob_inputs(input, blob_paths) 722 + 723 + // Transform union inputs from GraphQL discriminated format to AT Protocol format 724 + let union_fields = get_union_fields(collection, all_lex_jsons) 725 + let transformed_input = 726 + transform_union_inputs(blob_transformed, union_fields) 727 + 433 728 let record_json_value = graphql_value_to_json_value(transformed_input) 434 729 let record_json_string = json.to_string(record_json_value) 435 730 ··· 569 864 570 865 // Transform blob inputs from GraphQL format to AT Protocol format 571 866 let blob_paths = get_blob_paths(collection, all_lex_jsons) 572 - let transformed_input = transform_blob_inputs(input, blob_paths) 867 + let blob_transformed = transform_blob_inputs(input, blob_paths) 868 + 869 + // Transform union inputs from GraphQL discriminated format to AT Protocol format 870 + let union_fields = get_union_fields(collection, all_lex_jsons) 871 + let transformed_input = 872 + transform_union_inputs(blob_transformed, union_fields) 873 + 573 874 let record_json_value = graphql_value_to_json_value(transformed_input) 574 875 let record_json_string = json.to_string(record_json_value) 575 876 ··· 790 1091 Ok(blob_ref) 791 1092 } 792 1093 } 1094 + 1095 + /// Create a resolver for createReport mutation 1096 + /// Allows authenticated users to submit moderation reports 1097 + pub fn create_report_resolver_factory(ctx: MutationContext) -> schema.Resolver { 1098 + fn(resolver_ctx: schema.Context) -> Result(value.Value, String) { 1099 + // Get authenticated session using helper 1100 + use auth <- result.try(get_authenticated_session(resolver_ctx, ctx)) 1101 + 1102 + // Get subjectUri (required) and reasonType (required) from arguments 1103 + let subject_uri_result = case 1104 + schema.get_argument(resolver_ctx, "subjectUri") 1105 + { 1106 + option.Some(value.String(u)) -> Ok(u) 1107 + option.Some(_) -> Error("subjectUri must be a string") 1108 + option.None -> Error("Missing required argument: subjectUri") 1109 + } 1110 + 1111 + use subject_uri <- result.try(subject_uri_result) 1112 + 1113 + let reason_type_result = case 1114 + schema.get_argument(resolver_ctx, "reasonType") 1115 + { 1116 + option.Some(value.Enum(r)) -> Ok(string.lowercase(r)) 1117 + option.Some(value.String(r)) -> Ok(string.lowercase(r)) 1118 + option.Some(_) -> Error("reasonType must be a string") 1119 + option.None -> Error("Missing required argument: reasonType") 1120 + } 1121 + 1122 + use reason_type <- result.try(reason_type_result) 1123 + 1124 + // Validate reason_type 1125 + let valid_reasons = [ 1126 + "spam", 1127 + "violation", 1128 + "misleading", 1129 + "sexual", 1130 + "rude", 1131 + "other", 1132 + ] 1133 + use _ <- result.try(case list.contains(valid_reasons, reason_type) { 1134 + True -> Ok(Nil) 1135 + False -> 1136 + Error( 1137 + "Invalid reasonType. Must be one of: " 1138 + <> string.join(valid_reasons, ", "), 1139 + ) 1140 + }) 1141 + 1142 + // Get optional reason text 1143 + let reason = case schema.get_argument(resolver_ctx, "reason") { 1144 + option.Some(value.String(r)) -> option.Some(r) 1145 + _ -> option.None 1146 + } 1147 + 1148 + // Insert the report 1149 + use report <- result.try( 1150 + reports.insert( 1151 + ctx.db, 1152 + auth.user_info.did, 1153 + subject_uri, 1154 + reason_type, 1155 + reason, 1156 + ) 1157 + |> result.map_error(fn(_) { "Failed to create report" }), 1158 + ) 1159 + 1160 + // Return the created report 1161 + let reason_value = case report.reason { 1162 + option.Some(r) -> value.String(r) 1163 + option.None -> value.Null 1164 + } 1165 + 1166 + Ok( 1167 + value.Object([ 1168 + #("id", value.Int(report.id)), 1169 + #("reporterDid", value.String(report.reporter_did)), 1170 + #("subjectUri", value.String(report.subject_uri)), 1171 + #("reasonType", value.Enum(string.uppercase(report.reason_type))), 1172 + #("reason", reason_value), 1173 + #("status", value.Enum("PENDING")), 1174 + #("createdAt", value.String(report.created_at)), 1175 + ]), 1176 + ) 1177 + } 1178 + } 1179 + 1180 + // ─── Label Preference Mutation ──────────────────────────────────────────── 1181 + 1182 + /// Resolver factory for setLabelPreference mutation 1183 + pub fn set_label_preference_resolver_factory( 1184 + ctx: MutationContext, 1185 + ) -> schema.Resolver { 1186 + fn(resolver_ctx: schema.Context) -> Result(value.Value, String) { 1187 + // Get viewer auth (lightweight - no ATP session needed) 1188 + use user_info <- result.try(get_viewer_auth(resolver_ctx, ctx.db)) 1189 + 1190 + // Get val (required) argument 1191 + let val_result = case schema.get_argument(resolver_ctx, "val") { 1192 + option.Some(value.String(v)) -> Ok(v) 1193 + option.Some(_) -> Error("val must be a string") 1194 + option.None -> Error("Missing required argument: val") 1195 + } 1196 + 1197 + use val <- result.try(val_result) 1198 + 1199 + // Get visibility (required) argument 1200 + let visibility_result = case 1201 + schema.get_argument(resolver_ctx, "visibility") 1202 + { 1203 + option.Some(value.Enum(v)) -> Ok(string.lowercase(v)) 1204 + option.Some(value.String(v)) -> Ok(string.lowercase(v)) 1205 + option.Some(_) -> Error("visibility must be a valid enum value") 1206 + option.None -> Error("Missing required argument: visibility") 1207 + } 1208 + 1209 + use visibility <- result.try(visibility_result) 1210 + 1211 + // Validate not a system label (starts with !) 1212 + use _ <- result.try(case string.starts_with(val, "!") { 1213 + True -> Error("Cannot set preference for system labels") 1214 + False -> Ok(Nil) 1215 + }) 1216 + 1217 + // Validate visibility is a valid value 1218 + use _ <- result.try(label_definitions.validate_visibility(visibility)) 1219 + 1220 + // Validate label exists 1221 + use def <- result.try(case label_definitions.get(ctx.db, val) { 1222 + Ok(option.None) -> Error("Unknown label: " <> val) 1223 + Error(_) -> Error("Failed to validate label") 1224 + Ok(option.Some(d)) -> Ok(d) 1225 + }) 1226 + 1227 + // Set the preference 1228 + use _ <- result.try( 1229 + label_preferences.set(ctx.db, user_info.did, val, visibility) 1230 + |> result.map_error(fn(_) { "Failed to set label preference" }), 1231 + ) 1232 + 1233 + // Return the updated preference 1234 + Ok( 1235 + value.Object([ 1236 + #("val", value.String(def.val)), 1237 + #("description", value.String(def.description)), 1238 + #("severity", value.Enum(string.uppercase(def.severity))), 1239 + #( 1240 + "defaultVisibility", 1241 + value.Enum(string.uppercase(def.default_visibility)), 1242 + ), 1243 + #("visibility", value.Enum(string.uppercase(visibility))), 1244 + ]), 1245 + ) 1246 + } 1247 + }
+118 -1
server/src/graphql/lexicon/schema.gleam
··· 6 6 import backfill 7 7 import database/executor.{type Executor} 8 8 import database/repositories/config as config_repo 9 + import database/repositories/label_definitions 10 + import database/repositories/label_preferences 9 11 import database/repositories/lexicons 10 12 import gleam/dict 11 13 import gleam/dynamic/decode ··· 15 17 import gleam/option 16 18 import gleam/result 17 19 import gleam/string 20 + import graphql/admin/types as admin_types 18 21 import graphql/lexicon/converters 19 22 import graphql/lexicon/fetchers 20 23 import graphql/lexicon/mutations ··· 125 128 // Step 7: Create viewer state fetcher 126 129 let viewer_state_fetcher = fetchers.viewer_state_fetcher(db) 127 130 128 - // Step 8: Build schema with database-backed resolvers, mutations, and subscriptions 131 + // Step 8: Create labels fetcher 132 + let labels_fetch = fetchers.labels_fetcher(db) 133 + 134 + // Step 9: Build createReport mutation field 135 + let create_report_field = 136 + schema.field_with_args( 137 + "createReport", 138 + schema.non_null(admin_types.report_type()), 139 + "Submit a moderation report for content", 140 + [ 141 + schema.argument( 142 + "subjectUri", 143 + schema.non_null(schema.string_type()), 144 + "URI of the content to report (at:// or did:)", 145 + option.None, 146 + ), 147 + schema.argument( 148 + "reasonType", 149 + schema.non_null(admin_types.report_reason_type_enum()), 150 + "Type of report", 151 + option.None, 152 + ), 153 + schema.argument( 154 + "reason", 155 + schema.string_type(), 156 + "Optional additional details", 157 + option.None, 158 + ), 159 + ], 160 + mutations.create_report_resolver_factory(mutation_ctx), 161 + ) 162 + 163 + // Step 9b: Build setLabelPreference mutation field 164 + let set_label_pref_field = 165 + schema.field_with_args( 166 + "setLabelPreference", 167 + schema.non_null(admin_types.label_preference_type()), 168 + "Set visibility preference for a label type", 169 + [ 170 + schema.argument( 171 + "val", 172 + schema.non_null(schema.string_type()), 173 + "Label value", 174 + option.None, 175 + ), 176 + schema.argument( 177 + "visibility", 178 + schema.non_null(admin_types.label_visibility_enum()), 179 + "Visibility setting", 180 + option.None, 181 + ), 182 + ], 183 + mutations.set_label_preference_resolver_factory(mutation_ctx), 184 + ) 185 + 186 + // Step 10: Build viewerLabelPreferences query field 187 + let viewer_label_prefs_field = 188 + schema.field( 189 + "viewerLabelPreferences", 190 + schema.non_null( 191 + schema.list_type( 192 + schema.non_null(admin_types.label_preference_type()), 193 + ), 194 + ), 195 + "Get label preferences for the current user (non-system labels only)", 196 + fn(ctx) { 197 + // Get viewer_did from context variables (set by auth middleware) 198 + case schema.get_variable(ctx, "viewer_did") { 199 + option.Some(value.String(viewer_did)) -> { 200 + // Get non-system label definitions 201 + case label_definitions.get_non_system(mutation_ctx.db) { 202 + Ok(defs) -> { 203 + // Get user's preferences 204 + case 205 + label_preferences.get_by_did(mutation_ctx.db, viewer_did) 206 + { 207 + Ok(prefs) -> { 208 + // Build a map of label_val -> visibility 209 + let pref_map = 210 + list.fold(prefs, [], fn(acc, pref) { 211 + [#(pref.label_val, pref.visibility), ..acc] 212 + }) 213 + 214 + // Map each definition to a preference 215 + let result = 216 + list.map(defs, fn(def) { 217 + let visibility = case 218 + list.key_find(pref_map, def.val) 219 + { 220 + Ok(v) -> v 221 + Error(_) -> def.default_visibility 222 + } 223 + converters.label_preference_to_value( 224 + def, 225 + visibility, 226 + ) 227 + }) 228 + 229 + Ok(value.List(result)) 230 + } 231 + Error(_) -> Error("Failed to fetch label preferences") 232 + } 233 + } 234 + Error(_) -> Error("Failed to fetch label definitions") 235 + } 236 + } 237 + _ -> Error("Authentication required") 238 + } 239 + }, 240 + ) 241 + 242 + // Step 11: Build schema with database-backed resolvers, mutations, and subscriptions 129 243 database.build_schema_with_subscriptions( 130 244 parsed_lexicons, 131 245 record_fetcher, ··· 139 253 option.Some(viewer_fetcher), 140 254 option.Some(notification_fetcher), 141 255 option.Some(viewer_state_fetcher), 256 + option.Some(labels_fetch), 257 + option.Some([create_report_field, set_label_pref_field]), 258 + option.Some([viewer_label_prefs_field]), 142 259 ) 143 260 } 144 261 }
+100
server/src/handlers/graphiql.gleam
··· 107 107 |> wisp.set_header("content-type", "text/html; charset=utf-8") 108 108 |> wisp.set_body(wisp.Text(graphiql_html)) 109 109 } 110 + 111 + pub fn handle_admin_graphiql_request( 112 + req: wisp.Request, 113 + db: Executor, 114 + did_cache: Subject(did_cache.Message), 115 + ) -> wisp.Response { 116 + // Get token from session if logged in 117 + let oauth_token = case session.get_current_user(req, db, did_cache) { 118 + Ok(#(_did, _handle, access_token)) -> access_token 119 + Error(_) -> "" 120 + } 121 + let graphiql_html = "<!doctype html> 122 + <html lang=\"en\"> 123 + <head> 124 + <meta charset=\"UTF-8\" /> 125 + <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" /> 126 + <title>quickslice Admin GraphiQL</title> 127 + <style> 128 + body { 129 + margin: 0; 130 + } 131 + 132 + #graphiql { 133 + height: 100dvh; 134 + } 135 + 136 + .loading { 137 + height: 100%; 138 + display: flex; 139 + align-items: center; 140 + justify-content: center; 141 + font-size: 4rem; 142 + } 143 + </style> 144 + <link rel=\"stylesheet\" href=\"https://esm.sh/graphiql/dist/style.css\" /> 145 + <link 146 + rel=\"stylesheet\" 147 + href=\"https://esm.sh/@graphiql/plugin-explorer/dist/style.css\" 148 + /> 149 + <script type=\"importmap\"> 150 + { 151 + \"imports\": { 152 + \"react\": \"https://esm.sh/react@19.1.0\", 153 + \"react/\": \"https://esm.sh/react@19.1.0/\", 154 + 155 + \"react-dom\": \"https://esm.sh/react-dom@19.1.0\", 156 + \"react-dom/\": \"https://esm.sh/react-dom@19.1.0/\", 157 + 158 + \"graphiql\": \"https://esm.sh/graphiql?standalone&external=react,react-dom,@graphiql/react,graphql\", 159 + \"graphiql/\": \"https://esm.sh/graphiql/\", 160 + \"@graphiql/plugin-explorer\": \"https://esm.sh/@graphiql/plugin-explorer?standalone&external=react,@graphiql/react,graphql\", 161 + \"@graphiql/react\": \"https://esm.sh/@graphiql/react?standalone&external=react,react-dom,graphql,@graphiql/toolkit,@emotion/is-prop-valid\", 162 + 163 + \"@graphiql/toolkit\": \"https://esm.sh/@graphiql/toolkit?standalone&external=graphql\", 164 + \"graphql\": \"https://esm.sh/graphql@16.11.0\", 165 + \"@emotion/is-prop-valid\": \"data:text/javascript,\" 166 + } 167 + } 168 + </script> 169 + <script type=\"module\"> 170 + import React from 'react'; 171 + import ReactDOM from 'react-dom/client'; 172 + import { GraphiQL, HISTORY_PLUGIN } from 'graphiql'; 173 + import { createGraphiQLFetcher } from '@graphiql/toolkit'; 174 + import { explorerPlugin } from '@graphiql/plugin-explorer'; 175 + import 'graphiql/setup-workers/esm.sh'; 176 + 177 + const token = '" <> oauth_token <> "'; 178 + const fetcher = createGraphiQLFetcher({ 179 + url: '/admin/graphql', 180 + headers: token ? { 181 + 'Authorization': token 182 + } : {} 183 + }); 184 + const plugins = [HISTORY_PLUGIN, explorerPlugin()]; 185 + 186 + function App() { 187 + return React.createElement(GraphiQL, { 188 + fetcher, 189 + plugins, 190 + defaultEditorToolsVisibility: true, 191 + }); 192 + } 193 + 194 + const container = document.getElementById('graphiql'); 195 + const root = ReactDOM.createRoot(container); 196 + root.render(React.createElement(App)); 197 + </script> 198 + </head> 199 + <body> 200 + <div id=\"graphiql\"> 201 + <div class=\"loading\">Loading…</div> 202 + </div> 203 + </body> 204 + </html>" 205 + 206 + wisp.response(200) 207 + |> wisp.set_header("content-type", "text/html; charset=utf-8") 208 + |> wisp.set_body(wisp.Text(graphiql_html)) 209 + }
+2
server/src/server.gleam
··· 394 394 ) 395 395 ["graphiql"] -> 396 396 graphiql_handler.handle_graphiql_request(req, ctx.db, ctx.did_cache) 397 + ["graphiql", "admin"] -> 398 + graphiql_handler.handle_admin_graphiql_request(req, ctx.db, ctx.did_cache) 397 399 // MCP endpoint for AI assistant introspection 398 400 ["mcp"] -> { 399 401 let mcp_ctx =
+304
server/test/graphql/admin/connection_test.gleam
··· 1 + /// Integration tests for admin connection pagination 2 + /// 3 + /// Tests that labels and reports return proper connection structure 4 + /// with edges, pageInfo, and totalCount 5 + import database/repositories/labels 6 + import database/repositories/reports 7 + import gleam/list 8 + import gleam/option 9 + import gleeunit/should 10 + import test_helpers 11 + 12 + // ============================================================================= 13 + // Labels Connection Pagination Tests 14 + // ============================================================================= 15 + 16 + pub fn labels_get_paginated_returns_correct_structure_test() { 17 + let assert Ok(db) = test_helpers.create_test_db() 18 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 19 + 20 + let src = "did:plc:admin123" 21 + 22 + // Insert 5 labels 23 + let assert Ok(_) = 24 + labels.insert( 25 + db, 26 + src, 27 + "at://did:plc:user1/app.bsky.feed.post/1", 28 + option.None, 29 + "spam", 30 + option.None, 31 + ) 32 + let assert Ok(_) = 33 + labels.insert( 34 + db, 35 + src, 36 + "at://did:plc:user2/app.bsky.feed.post/2", 37 + option.None, 38 + "spam", 39 + option.None, 40 + ) 41 + let assert Ok(_) = 42 + labels.insert( 43 + db, 44 + src, 45 + "at://did:plc:user3/app.bsky.feed.post/3", 46 + option.None, 47 + "spam", 48 + option.None, 49 + ) 50 + let assert Ok(_) = 51 + labels.insert( 52 + db, 53 + src, 54 + "at://did:plc:user4/app.bsky.feed.post/4", 55 + option.None, 56 + "!warn", 57 + option.None, 58 + ) 59 + let assert Ok(_) = 60 + labels.insert( 61 + db, 62 + src, 63 + "at://did:plc:user5/app.bsky.feed.post/5", 64 + option.None, 65 + "spam", 66 + option.None, 67 + ) 68 + 69 + // Get first 2 labels 70 + let assert Ok(paginated) = 71 + labels.get_paginated(db, option.None, option.None, 2, option.None) 72 + 73 + // Should return 2 labels 74 + paginated.labels |> list.length() |> should.equal(2) 75 + // Should have next page (3 more labels) 76 + paginated.has_next_page |> should.equal(True) 77 + // Total count should be 5 78 + paginated.total_count |> should.equal(5) 79 + } 80 + 81 + pub fn labels_get_paginated_with_filter_test() { 82 + let assert Ok(db) = test_helpers.create_test_db() 83 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 84 + 85 + let src = "did:plc:admin123" 86 + 87 + // Insert labels with different vals 88 + let assert Ok(_) = 89 + labels.insert( 90 + db, 91 + src, 92 + "at://did:plc:user1/app.bsky.feed.post/1", 93 + option.None, 94 + "spam", 95 + option.None, 96 + ) 97 + let assert Ok(_) = 98 + labels.insert( 99 + db, 100 + src, 101 + "at://did:plc:user2/app.bsky.feed.post/2", 102 + option.None, 103 + "!warn", 104 + option.None, 105 + ) 106 + let assert Ok(_) = 107 + labels.insert( 108 + db, 109 + src, 110 + "at://did:plc:user3/app.bsky.feed.post/3", 111 + option.None, 112 + "spam", 113 + option.None, 114 + ) 115 + 116 + // Filter by val = "spam" 117 + let assert Ok(paginated) = 118 + labels.get_paginated(db, option.None, option.Some("spam"), 10, option.None) 119 + 120 + // Should return 2 spam labels 121 + paginated.labels |> list.length() |> should.equal(2) 122 + // No next page 123 + paginated.has_next_page |> should.equal(False) 124 + // Total count of spam labels is 2 125 + paginated.total_count |> should.equal(2) 126 + } 127 + 128 + pub fn labels_get_paginated_with_cursor_test() { 129 + let assert Ok(db) = test_helpers.create_test_db() 130 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 131 + 132 + let src = "did:plc:admin123" 133 + 134 + // Insert 4 labels 135 + let assert Ok(_) = 136 + labels.insert( 137 + db, 138 + src, 139 + "at://did:plc:user1/app.bsky.feed.post/1", 140 + option.None, 141 + "spam", 142 + option.None, 143 + ) 144 + let assert Ok(_) = 145 + labels.insert( 146 + db, 147 + src, 148 + "at://did:plc:user2/app.bsky.feed.post/2", 149 + option.None, 150 + "spam", 151 + option.None, 152 + ) 153 + let assert Ok(_) = 154 + labels.insert( 155 + db, 156 + src, 157 + "at://did:plc:user3/app.bsky.feed.post/3", 158 + option.None, 159 + "spam", 160 + option.None, 161 + ) 162 + let assert Ok(_) = 163 + labels.insert( 164 + db, 165 + src, 166 + "at://did:plc:user4/app.bsky.feed.post/4", 167 + option.None, 168 + "spam", 169 + option.None, 170 + ) 171 + 172 + // Get first page (2 items) 173 + let assert Ok(page1) = 174 + labels.get_paginated(db, option.None, option.None, 2, option.None) 175 + 176 + page1.labels |> list.length() |> should.equal(2) 177 + page1.has_next_page |> should.equal(True) 178 + page1.total_count |> should.equal(4) 179 + 180 + // Get last item from first page to use as cursor 181 + let assert Ok(last_label) = list.last(page1.labels) 182 + 183 + // Get second page using cursor 184 + let assert Ok(page2) = 185 + labels.get_paginated( 186 + db, 187 + option.None, 188 + option.None, 189 + 2, 190 + option.Some(last_label.id), 191 + ) 192 + 193 + page2.labels |> list.length() |> should.equal(2) 194 + page2.has_next_page |> should.equal(False) 195 + // Total count is still 4 (cursor doesn't affect total) 196 + page2.total_count |> should.equal(4) 197 + 198 + // Verify second page has different labels (lower IDs) 199 + let assert Ok(first_label_page2) = list.first(page2.labels) 200 + { first_label_page2.id < last_label.id } |> should.equal(True) 201 + } 202 + 203 + pub fn labels_get_paginated_empty_result_test() { 204 + let assert Ok(db) = test_helpers.create_test_db() 205 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 206 + 207 + // Get paginated with no labels 208 + let assert Ok(paginated) = 209 + labels.get_paginated(db, option.None, option.None, 10, option.None) 210 + 211 + paginated.labels |> list.length() |> should.equal(0) 212 + paginated.has_next_page |> should.equal(False) 213 + paginated.total_count |> should.equal(0) 214 + } 215 + 216 + // ============================================================================= 217 + // Reports Connection Pagination Tests 218 + // ============================================================================= 219 + 220 + pub fn reports_get_paginated_returns_correct_structure_test() { 221 + let assert Ok(db) = test_helpers.create_test_db() 222 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 223 + 224 + let reporter = "did:plc:reporter123" 225 + 226 + // Insert 4 reports 227 + let assert Ok(_) = 228 + reports.insert(db, reporter, "at://did/post/1", "spam", option.None) 229 + let assert Ok(_) = 230 + reports.insert(db, reporter, "at://did/post/2", "violation", option.None) 231 + let assert Ok(_) = 232 + reports.insert(db, reporter, "at://did/post/3", "rude", option.None) 233 + let assert Ok(_) = 234 + reports.insert(db, reporter, "at://did/post/4", "spam", option.None) 235 + 236 + // Get first 2 reports 237 + let assert Ok(paginated) = 238 + reports.get_paginated(db, option.None, 2, option.None) 239 + 240 + paginated.reports |> list.length() |> should.equal(2) 241 + paginated.has_next_page |> should.equal(True) 242 + paginated.total_count |> should.equal(4) 243 + } 244 + 245 + pub fn reports_get_paginated_with_status_filter_test() { 246 + let assert Ok(db) = test_helpers.create_test_db() 247 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 248 + 249 + let reporter = "did:plc:reporter123" 250 + let admin = "did:plc:admin123" 251 + 252 + // Insert reports 253 + let assert Ok(report1) = 254 + reports.insert(db, reporter, "at://did/post/1", "spam", option.None) 255 + let assert Ok(_) = 256 + reports.insert(db, reporter, "at://did/post/2", "violation", option.None) 257 + let assert Ok(_) = 258 + reports.insert(db, reporter, "at://did/post/3", "rude", option.None) 259 + 260 + // Resolve first report 261 + let assert Ok(_) = reports.resolve(db, report1.id, "resolved", admin) 262 + 263 + // Filter by status = "pending" 264 + let assert Ok(paginated) = 265 + reports.get_paginated(db, option.Some("pending"), 10, option.None) 266 + 267 + paginated.reports |> list.length() |> should.equal(2) 268 + paginated.has_next_page |> should.equal(False) 269 + paginated.total_count |> should.equal(2) 270 + } 271 + 272 + pub fn reports_get_paginated_with_cursor_test() { 273 + let assert Ok(db) = test_helpers.create_test_db() 274 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 275 + 276 + let reporter = "did:plc:reporter123" 277 + 278 + // Insert 4 reports 279 + let assert Ok(_) = 280 + reports.insert(db, reporter, "at://did/post/1", "spam", option.None) 281 + let assert Ok(_) = 282 + reports.insert(db, reporter, "at://did/post/2", "spam", option.None) 283 + let assert Ok(_) = 284 + reports.insert(db, reporter, "at://did/post/3", "spam", option.None) 285 + let assert Ok(_) = 286 + reports.insert(db, reporter, "at://did/post/4", "spam", option.None) 287 + 288 + // Get first page 289 + let assert Ok(page1) = reports.get_paginated(db, option.None, 2, option.None) 290 + 291 + page1.reports |> list.length() |> should.equal(2) 292 + page1.has_next_page |> should.equal(True) 293 + 294 + // Get last item from first page 295 + let assert Ok(last_report) = list.last(page1.reports) 296 + 297 + // Get second page using cursor 298 + let assert Ok(page2) = 299 + reports.get_paginated(db, option.None, 2, option.Some(last_report.id)) 300 + 301 + page2.reports |> list.length() |> should.equal(2) 302 + page2.has_next_page |> should.equal(False) 303 + page2.total_count |> should.equal(4) 304 + }
+33
server/test/graphql/admin/cursor_test.gleam
··· 1 + import gleeunit/should 2 + import graphql/admin/cursor 3 + 4 + pub fn encode_cursor_test() { 5 + cursor.encode("Label", 42) 6 + |> should.equal("TGFiZWw6NDI=") 7 + } 8 + 9 + pub fn encode_cursor_with_large_id_test() { 10 + cursor.encode("Report", 12_345) 11 + |> should.equal("UmVwb3J0OjEyMzQ1") 12 + } 13 + 14 + pub fn decode_cursor_test() { 15 + cursor.decode("TGFiZWw6NDI=") 16 + |> should.equal(Ok(#("Label", 42))) 17 + } 18 + 19 + pub fn decode_cursor_with_large_id_test() { 20 + cursor.decode("UmVwb3J0OjEyMzQ1") 21 + |> should.equal(Ok(#("Report", 12_345))) 22 + } 23 + 24 + pub fn decode_invalid_cursor_test() { 25 + cursor.decode("not-valid-base64!!!") 26 + |> should.be_error() 27 + } 28 + 29 + pub fn decode_malformed_cursor_test() { 30 + // Valid base64 but wrong format (no colon) 31 + cursor.decode("bm9jb2xvbg==") 32 + |> should.be_error() 33 + }
+6
server/test/groupby_enum_validation_test.gleam
··· 58 58 option.None, 59 59 option.None, 60 60 option.None, 61 + option.None, 62 + option.None, 63 + option.None, 61 64 ) 62 65 63 66 // Introspection query to check if AppBskyFeedPostGroupByField enum exists ··· 206 209 option.None, 207 210 option.None, 208 211 option.Some(stub_aggregate_fetcher), 212 + option.None, 213 + option.None, 214 + option.None, 209 215 option.None, 210 216 option.None, 211 217 option.None,
+378
server/test/label_preferences_integration_test.gleam
··· 1 + /// Integration tests for viewer label preferences 2 + /// 3 + /// Tests the viewerLabelPreferences query and setLabelPreference mutation 4 + /// via the GraphQL API endpoint 5 + import database/repositories/lexicons 6 + import gleam/http 7 + import gleam/json 8 + import gleam/option.{None} 9 + import gleam/string 10 + import gleeunit/should 11 + import handlers/graphql as graphql_handler 12 + import lib/oauth/did_cache 13 + import test_helpers 14 + import wisp 15 + import wisp/simulate 16 + 17 + /// Create a minimal lexicon for schema building 18 + fn create_minimal_lexicon() -> String { 19 + json.object([ 20 + #("lexicon", json.int(1)), 21 + #("id", json.string("test.minimal.record")), 22 + #( 23 + "defs", 24 + json.object([ 25 + #( 26 + "main", 27 + json.object([ 28 + #("type", json.string("record")), 29 + #("key", json.string("tid")), 30 + #( 31 + "record", 32 + json.object([ 33 + #("type", json.string("object")), 34 + #( 35 + "required", 36 + json.array([json.string("name")], of: fn(x) { x }), 37 + ), 38 + #( 39 + "properties", 40 + json.object([ 41 + #("name", json.object([#("type", json.string("string"))])), 42 + ]), 43 + ), 44 + ]), 45 + ), 46 + ]), 47 + ), 48 + ]), 49 + ), 50 + ]) 51 + |> json.to_string 52 + } 53 + 54 + /// Test: viewerLabelPreferences returns non-system labels with default visibility 55 + pub fn viewer_label_preferences_returns_defaults_test() { 56 + // Setup database with moderation tables 57 + let assert Ok(exec) = test_helpers.create_test_db() 58 + let assert Ok(_) = test_helpers.create_core_tables(exec) 59 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 60 + let assert Ok(_) = test_helpers.create_moderation_tables(exec) 61 + let assert Ok(_) = 62 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 63 + // Insert a minimal lexicon so schema can build 64 + let assert Ok(_) = 65 + lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon()) 66 + 67 + // Query for viewer label preferences 68 + let query = 69 + json.object([ 70 + #( 71 + "query", 72 + json.string( 73 + "{ viewerLabelPreferences { val description defaultVisibility visibility } }", 74 + ), 75 + ), 76 + ]) 77 + |> json.to_string 78 + 79 + let request = 80 + simulate.request(http.Post, "/graphql") 81 + |> simulate.string_body(query) 82 + |> simulate.header("content-type", "application/json") 83 + |> simulate.header("authorization", "Bearer test-viewer-token") 84 + 85 + let assert Ok(cache) = did_cache.start() 86 + let response = 87 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 88 + 89 + let assert wisp.Text(body) = response.body 90 + 91 + // Query should succeed 92 + response.status |> should.equal(200) 93 + 94 + // Should contain non-system labels (porn, spam, sexual, nudity) 95 + string.contains(body, "porn") |> should.be_true 96 + string.contains(body, "spam") |> should.be_true 97 + string.contains(body, "sexual") |> should.be_true 98 + string.contains(body, "nudity") |> should.be_true 99 + 100 + // Should NOT contain system labels (starting with !) 101 + string.contains(body, "!takedown") |> should.be_false 102 + string.contains(body, "!suspend") |> should.be_false 103 + string.contains(body, "!warn") |> should.be_false 104 + string.contains(body, "!hide") |> should.be_false 105 + 106 + // Should have visibility field with default values 107 + string.contains(body, "visibility") |> should.be_true 108 + } 109 + 110 + /// Test: viewerLabelPreferences requires authentication 111 + pub fn viewer_label_preferences_requires_auth_test() { 112 + // Setup database 113 + let assert Ok(exec) = test_helpers.create_test_db() 114 + let assert Ok(_) = test_helpers.create_core_tables(exec) 115 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 116 + let assert Ok(_) = test_helpers.create_moderation_tables(exec) 117 + // Insert a minimal lexicon so schema can build 118 + let assert Ok(_) = 119 + lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon()) 120 + 121 + // Query WITHOUT auth token 122 + let query = 123 + json.object([ 124 + #("query", json.string("{ viewerLabelPreferences { val visibility } }")), 125 + ]) 126 + |> json.to_string 127 + 128 + let request = 129 + simulate.request(http.Post, "/graphql") 130 + |> simulate.string_body(query) 131 + |> simulate.header("content-type", "application/json") 132 + 133 + let assert Ok(cache) = did_cache.start() 134 + let response = 135 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 136 + 137 + let assert wisp.Text(body) = response.body 138 + 139 + // Should return error about authentication 140 + string.contains(body, "error") |> should.be_true 141 + string.contains(body, "Authentication") |> should.be_true 142 + } 143 + 144 + /// Test: setLabelPreference updates visibility for non-system label 145 + pub fn set_label_preference_updates_visibility_test() { 146 + // Setup database 147 + let assert Ok(exec) = test_helpers.create_test_db() 148 + let assert Ok(_) = test_helpers.create_core_tables(exec) 149 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 150 + let assert Ok(_) = test_helpers.create_moderation_tables(exec) 151 + let assert Ok(_) = 152 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 153 + // Insert a minimal lexicon so schema can build 154 + let assert Ok(_) = 155 + lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon()) 156 + 157 + // Set preference for 'spam' label to 'hide' 158 + let mutation = 159 + json.object([ 160 + #( 161 + "query", 162 + json.string( 163 + "mutation { setLabelPreference(val: \"spam\", visibility: HIDE) { val visibility } }", 164 + ), 165 + ), 166 + ]) 167 + |> json.to_string 168 + 169 + let request = 170 + simulate.request(http.Post, "/graphql") 171 + |> simulate.string_body(mutation) 172 + |> simulate.header("content-type", "application/json") 173 + |> simulate.header("authorization", "Bearer test-viewer-token") 174 + 175 + let assert Ok(cache) = did_cache.start() 176 + let response = 177 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 178 + 179 + let assert wisp.Text(body) = response.body 180 + 181 + // Mutation should succeed 182 + response.status |> should.equal(200) 183 + 184 + // Should return the updated preference 185 + string.contains(body, "spam") |> should.be_true 186 + string.contains(body, "HIDE") |> should.be_true 187 + 188 + // Verify the preference persisted by querying again 189 + let query = 190 + json.object([ 191 + #("query", json.string("{ viewerLabelPreferences { val visibility } }")), 192 + ]) 193 + |> json.to_string 194 + 195 + let verify_request = 196 + simulate.request(http.Post, "/graphql") 197 + |> simulate.string_body(query) 198 + |> simulate.header("content-type", "application/json") 199 + |> simulate.header("authorization", "Bearer test-viewer-token") 200 + 201 + let verify_response = 202 + graphql_handler.handle_graphql_request( 203 + verify_request, 204 + exec, 205 + cache, 206 + None, 207 + "", 208 + "", 209 + ) 210 + 211 + let assert wisp.Text(verify_body) = verify_response.body 212 + 213 + // Should show the updated visibility for spam 214 + string.contains(verify_body, "HIDE") |> should.be_true 215 + } 216 + 217 + /// Test: setLabelPreference rejects system labels (starting with !) 218 + pub fn set_label_preference_rejects_system_labels_test() { 219 + // Setup database 220 + let assert Ok(exec) = test_helpers.create_test_db() 221 + let assert Ok(_) = test_helpers.create_core_tables(exec) 222 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 223 + let assert Ok(_) = test_helpers.create_moderation_tables(exec) 224 + let assert Ok(_) = 225 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 226 + // Insert a minimal lexicon so schema can build 227 + let assert Ok(_) = 228 + lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon()) 229 + 230 + // Try to set preference for system label '!takedown' 231 + let mutation = 232 + json.object([ 233 + #( 234 + "query", 235 + json.string( 236 + "mutation { setLabelPreference(val: \"!takedown\", visibility: IGNORE) { val visibility } }", 237 + ), 238 + ), 239 + ]) 240 + |> json.to_string 241 + 242 + let request = 243 + simulate.request(http.Post, "/graphql") 244 + |> simulate.string_body(mutation) 245 + |> simulate.header("content-type", "application/json") 246 + |> simulate.header("authorization", "Bearer test-viewer-token") 247 + 248 + let assert Ok(cache) = did_cache.start() 249 + let response = 250 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 251 + 252 + let assert wisp.Text(body) = response.body 253 + 254 + // Should return error about system labels 255 + string.contains(body, "error") |> should.be_true 256 + string.contains(body, "system") |> should.be_true 257 + } 258 + 259 + /// Test: setLabelPreference requires authentication 260 + pub fn set_label_preference_requires_auth_test() { 261 + // Setup database 262 + let assert Ok(exec) = test_helpers.create_test_db() 263 + let assert Ok(_) = test_helpers.create_core_tables(exec) 264 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 265 + let assert Ok(_) = test_helpers.create_moderation_tables(exec) 266 + // Insert a minimal lexicon so schema can build 267 + let assert Ok(_) = 268 + lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon()) 269 + 270 + // Try to set preference WITHOUT auth token 271 + let mutation = 272 + json.object([ 273 + #( 274 + "query", 275 + json.string( 276 + "mutation { setLabelPreference(val: \"spam\", visibility: HIDE) { val visibility } }", 277 + ), 278 + ), 279 + ]) 280 + |> json.to_string 281 + 282 + let request = 283 + simulate.request(http.Post, "/graphql") 284 + |> simulate.string_body(mutation) 285 + |> simulate.header("content-type", "application/json") 286 + 287 + let assert Ok(cache) = did_cache.start() 288 + let response = 289 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 290 + 291 + let assert wisp.Text(body) = response.body 292 + 293 + // Should return error about authentication 294 + string.contains(body, "error") |> should.be_true 295 + string.contains(body, "Authentication") |> should.be_true 296 + } 297 + 298 + /// Test: setLabelPreference validates visibility value 299 + pub fn set_label_preference_validates_visibility_test() { 300 + // Setup database 301 + let assert Ok(exec) = test_helpers.create_test_db() 302 + let assert Ok(_) = test_helpers.create_core_tables(exec) 303 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 304 + let assert Ok(_) = test_helpers.create_moderation_tables(exec) 305 + let assert Ok(_) = 306 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 307 + // Insert a minimal lexicon so schema can build 308 + let assert Ok(_) = 309 + lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon()) 310 + 311 + // Try to set invalid visibility (GraphQL enum validation should catch this) 312 + let mutation = 313 + json.object([ 314 + #( 315 + "query", 316 + json.string( 317 + "mutation { setLabelPreference(val: \"spam\", visibility: INVALID) { val visibility } }", 318 + ), 319 + ), 320 + ]) 321 + |> json.to_string 322 + 323 + let request = 324 + simulate.request(http.Post, "/graphql") 325 + |> simulate.string_body(mutation) 326 + |> simulate.header("content-type", "application/json") 327 + |> simulate.header("authorization", "Bearer test-viewer-token") 328 + 329 + let assert Ok(cache) = did_cache.start() 330 + let response = 331 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 332 + 333 + let assert wisp.Text(body) = response.body 334 + 335 + // Should return error about invalid enum value 336 + string.contains(body, "error") |> should.be_true 337 + } 338 + 339 + /// Test: setLabelPreference rejects non-existent label 340 + pub fn set_label_preference_rejects_unknown_label_test() { 341 + // Setup database 342 + let assert Ok(exec) = test_helpers.create_test_db() 343 + let assert Ok(_) = test_helpers.create_core_tables(exec) 344 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 345 + let assert Ok(_) = test_helpers.create_moderation_tables(exec) 346 + let assert Ok(_) = 347 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 348 + // Insert a minimal lexicon so schema can build 349 + let assert Ok(_) = 350 + lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon()) 351 + 352 + // Try to set preference for non-existent label 353 + let mutation = 354 + json.object([ 355 + #( 356 + "query", 357 + json.string( 358 + "mutation { setLabelPreference(val: \"nonexistent\", visibility: HIDE) { val visibility } }", 359 + ), 360 + ), 361 + ]) 362 + |> json.to_string 363 + 364 + let request = 365 + simulate.request(http.Post, "/graphql") 366 + |> simulate.string_body(mutation) 367 + |> simulate.header("content-type", "application/json") 368 + |> simulate.header("authorization", "Bearer test-viewer-token") 369 + 370 + let assert Ok(cache) = did_cache.start() 371 + let response = 372 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 373 + 374 + let assert wisp.Text(body) = response.body 375 + 376 + // Should return error about label not found 377 + string.contains(body, "error") |> should.be_true 378 + }
+185
server/test/label_preferences_test.gleam
··· 1 + /// Integration tests for label preferences 2 + /// 3 + /// Tests the label preference system including: 4 + /// - Setting label preferences 5 + /// - Getting label preferences for a user 6 + /// - Non-system label filtering 7 + /// - Validation of visibility values 8 + import database/repositories/label_definitions 9 + import database/repositories/label_preferences 10 + import gleam/list 11 + import gleam/option 12 + import gleam/string 13 + import gleeunit/should 14 + import test_helpers 15 + 16 + // ============================================================================= 17 + // Label Definitions Repository Tests (with default_visibility) 18 + // ============================================================================= 19 + 20 + pub fn label_definitions_get_includes_default_visibility_test() { 21 + let assert Ok(db) = test_helpers.create_test_db() 22 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 23 + 24 + // Get a label definition 25 + let assert Ok(option.Some(def)) = label_definitions.get(db, "spam") 26 + 27 + // Should have default_visibility field 28 + def.val |> should.equal("spam") 29 + def.default_visibility |> should.equal("warn") 30 + } 31 + 32 + pub fn label_definitions_get_non_system_excludes_system_labels_test() { 33 + let assert Ok(db) = test_helpers.create_test_db() 34 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 35 + 36 + // Get non-system labels 37 + let assert Ok(defs) = label_definitions.get_non_system(db) 38 + 39 + // Should have at least some labels 40 + list.length(defs) |> should.not_equal(0) 41 + 42 + // None should start with ! 43 + list.all(defs, fn(def) { !string.starts_with(def.val, "!") }) 44 + |> should.be_true() 45 + } 46 + 47 + pub fn label_definitions_get_non_system_includes_content_labels_test() { 48 + let assert Ok(db) = test_helpers.create_test_db() 49 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 50 + 51 + // Get non-system labels 52 + let assert Ok(defs) = label_definitions.get_non_system(db) 53 + 54 + // Should include porn, spam, etc. 55 + let vals = list.map(defs, fn(d) { d.val }) 56 + 57 + vals |> list.contains("porn") |> should.be_true() 58 + vals |> list.contains("spam") |> should.be_true() 59 + vals |> list.contains("sexual") |> should.be_true() 60 + vals |> list.contains("nudity") |> should.be_true() 61 + } 62 + 63 + // ============================================================================= 64 + // Label Preferences Repository Tests 65 + // ============================================================================= 66 + 67 + pub fn label_preferences_set_and_get_test() { 68 + let assert Ok(db) = test_helpers.create_test_db() 69 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 70 + 71 + let did = "did:plc:user123" 72 + let label_val = "spam" 73 + let visibility = "hide" 74 + 75 + // Set a preference 76 + let assert Ok(pref) = label_preferences.set(db, did, label_val, visibility) 77 + 78 + pref.did |> should.equal(did) 79 + pref.label_val |> should.equal(label_val) 80 + pref.visibility |> should.equal(visibility) 81 + 82 + // Get the preference 83 + let assert Ok(option.Some(found)) = label_preferences.get(db, did, label_val) 84 + 85 + found.visibility |> should.equal(visibility) 86 + } 87 + 88 + pub fn label_preferences_get_by_did_returns_all_user_prefs_test() { 89 + let assert Ok(db) = test_helpers.create_test_db() 90 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 91 + 92 + let did = "did:plc:user123" 93 + 94 + // Set multiple preferences 95 + let assert Ok(_) = label_preferences.set(db, did, "spam", "hide") 96 + let assert Ok(_) = label_preferences.set(db, did, "porn", "warn") 97 + let assert Ok(_) = label_preferences.set(db, did, "nudity", "ignore") 98 + 99 + // Get all preferences for user 100 + let assert Ok(prefs) = label_preferences.get_by_did(db, did) 101 + 102 + prefs |> list.length() |> should.equal(3) 103 + 104 + // Verify each preference exists 105 + let vals = list.map(prefs, fn(p) { p.label_val }) 106 + vals |> list.contains("spam") |> should.be_true() 107 + vals |> list.contains("porn") |> should.be_true() 108 + vals |> list.contains("nudity") |> should.be_true() 109 + } 110 + 111 + pub fn label_preferences_set_updates_existing_test() { 112 + let assert Ok(db) = test_helpers.create_test_db() 113 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 114 + 115 + let did = "did:plc:user123" 116 + let label_val = "spam" 117 + 118 + // Set initial preference 119 + let assert Ok(_) = label_preferences.set(db, did, label_val, "warn") 120 + 121 + // Update preference 122 + let assert Ok(updated) = label_preferences.set(db, did, label_val, "hide") 123 + 124 + updated.visibility |> should.equal("hide") 125 + 126 + // Verify only one preference exists 127 + let assert Ok(prefs) = label_preferences.get_by_did(db, did) 128 + prefs |> list.length() |> should.equal(1) 129 + } 130 + 131 + pub fn label_preferences_delete_removes_preference_test() { 132 + let assert Ok(db) = test_helpers.create_test_db() 133 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 134 + 135 + let did = "did:plc:user123" 136 + let label_val = "spam" 137 + 138 + // Set a preference 139 + let assert Ok(_) = label_preferences.set(db, did, label_val, "hide") 140 + 141 + // Delete the preference 142 + let assert Ok(_) = label_preferences.delete(db, did, label_val) 143 + 144 + // Should no longer exist 145 + let assert Ok(option.None) = label_preferences.get(db, did, label_val) 146 + } 147 + 148 + pub fn label_preferences_different_users_have_separate_prefs_test() { 149 + let assert Ok(db) = test_helpers.create_test_db() 150 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 151 + 152 + let user1 = "did:plc:user1" 153 + let user2 = "did:plc:user2" 154 + 155 + // Set different preferences for different users 156 + let assert Ok(_) = label_preferences.set(db, user1, "spam", "hide") 157 + let assert Ok(_) = label_preferences.set(db, user2, "spam", "warn") 158 + 159 + // Get user1's preference 160 + let assert Ok(option.Some(pref1)) = label_preferences.get(db, user1, "spam") 161 + pref1.visibility |> should.equal("hide") 162 + 163 + // Get user2's preference 164 + let assert Ok(option.Some(pref2)) = label_preferences.get(db, user2, "spam") 165 + pref2.visibility |> should.equal("warn") 166 + } 167 + 168 + pub fn label_preferences_get_returns_none_when_not_set_test() { 169 + let assert Ok(db) = test_helpers.create_test_db() 170 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 171 + 172 + // Get a preference that doesn't exist 173 + let assert Ok(option.None) = 174 + label_preferences.get(db, "did:plc:user123", "spam") 175 + } 176 + 177 + pub fn label_preferences_get_by_did_returns_empty_for_new_user_test() { 178 + let assert Ok(db) = test_helpers.create_test_db() 179 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 180 + 181 + // Get preferences for a user with no preferences 182 + let assert Ok(prefs) = label_preferences.get_by_did(db, "did:plc:newuser") 183 + 184 + prefs |> list.length() |> should.equal(0) 185 + }
+376
server/test/labels_test.gleam
··· 1 + /// Integration tests for labels and reports 2 + /// 3 + /// Tests the moderation system including: 4 + /// - Label creation and retrieval 5 + /// - Report creation and retrieval 6 + /// - Takedown filtering in record queries 7 + /// - Self-labels parsing and merging 8 + import database/repositories/labels 9 + import database/repositories/reports 10 + import gleam/dict 11 + import gleam/list 12 + import gleam/option 13 + import gleeunit/should 14 + import graphql/lexicon/fetchers 15 + import swell/value 16 + import test_helpers 17 + 18 + // ============================================================================= 19 + // Label Repository Tests 20 + // ============================================================================= 21 + 22 + pub fn label_insert_and_get_by_uri_test() { 23 + // Setup test database 24 + let assert Ok(db) = test_helpers.create_test_db() 25 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 26 + 27 + let src = "did:plc:admin123" 28 + let uri = "at://did:plc:user1/app.bsky.feed.post/abc123" 29 + let val = "spam" 30 + 31 + // Insert a label 32 + let assert Ok(label) = 33 + labels.insert(db, src, uri, option.None, val, option.None) 34 + 35 + // Verify label fields 36 + label.src |> should.equal(src) 37 + label.uri |> should.equal(uri) 38 + label.val |> should.equal(val) 39 + label.neg |> should.equal(False) 40 + 41 + // Get by URI should return the label 42 + let assert Ok(found_labels) = labels.get_by_uris(db, [uri]) 43 + found_labels |> list.length() |> should.equal(1) 44 + 45 + let assert [found] = found_labels 46 + found.val |> should.equal(val) 47 + } 48 + 49 + pub fn label_negation_test() { 50 + let assert Ok(db) = test_helpers.create_test_db() 51 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 52 + 53 + let src = "did:plc:admin123" 54 + let uri = "at://did:plc:user1/app.bsky.feed.post/abc123" 55 + 56 + // Insert a label 57 + let assert Ok(_) = 58 + labels.insert(db, src, uri, option.None, "spam", option.None) 59 + 60 + // Insert negation 61 + let assert Ok(neg_label) = labels.insert_negation(db, src, uri, "spam") 62 + neg_label.neg |> should.equal(True) 63 + 64 + // get_by_uris should only return active (non-negated) labels 65 + let assert Ok(found_labels) = labels.get_by_uris(db, [uri]) 66 + // The negation retracts the original label, so get_by_uris should return 0 67 + // (The original is excluded because a later negation exists for it) 68 + found_labels |> list.length() |> should.equal(0) 69 + } 70 + 71 + pub fn label_get_all_test() { 72 + let assert Ok(db) = test_helpers.create_test_db() 73 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 74 + 75 + let src = "did:plc:admin123" 76 + 77 + // Insert multiple labels 78 + let assert Ok(_) = 79 + labels.insert( 80 + db, 81 + src, 82 + "at://did:plc:user1/app.bsky.feed.post/1", 83 + option.None, 84 + "spam", 85 + option.None, 86 + ) 87 + let assert Ok(_) = 88 + labels.insert( 89 + db, 90 + src, 91 + "at://did:plc:user2/app.bsky.feed.post/2", 92 + option.None, 93 + "!warn", 94 + option.None, 95 + ) 96 + let assert Ok(_) = 97 + labels.insert( 98 + db, 99 + src, 100 + "at://did:plc:user3/app.bsky.feed.post/3", 101 + option.None, 102 + "spam", 103 + option.None, 104 + ) 105 + 106 + // Get all with no filters 107 + let assert Ok(all_labels) = 108 + labels.get_all(db, option.None, option.None, 100, option.None) 109 + all_labels |> list.length() |> should.equal(3) 110 + 111 + // Filter by val 112 + let assert Ok(spam_labels) = 113 + labels.get_all(db, option.None, option.Some("spam"), 100, option.None) 114 + spam_labels |> list.length() |> should.equal(2) 115 + } 116 + 117 + // ============================================================================= 118 + // Takedown Filtering Tests 119 + // ============================================================================= 120 + 121 + pub fn takedown_uris_returns_takedown_labels_test() { 122 + let assert Ok(db) = test_helpers.create_test_db() 123 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 124 + 125 + let src = "did:plc:admin123" 126 + let takedown_uri = "at://did:plc:user1/app.bsky.feed.post/takedown" 127 + let normal_uri = "at://did:plc:user2/app.bsky.feed.post/normal" 128 + let suspend_uri = "at://did:plc:user3/app.bsky.feed.post/suspend" 129 + 130 + // Insert labels - takedown, normal, and suspend 131 + let assert Ok(_) = 132 + labels.insert(db, src, takedown_uri, option.None, "!takedown", option.None) 133 + let assert Ok(_) = 134 + labels.insert(db, src, normal_uri, option.None, "spam", option.None) 135 + let assert Ok(_) = 136 + labels.insert(db, src, suspend_uri, option.None, "!suspend", option.None) 137 + 138 + // get_takedown_uris should return only !takedown and !suspend URIs 139 + let assert Ok(takedown_list) = 140 + labels.get_takedown_uris(db, [takedown_uri, normal_uri, suspend_uri]) 141 + 142 + takedown_list |> list.length() |> should.equal(2) 143 + takedown_list |> list.contains(takedown_uri) |> should.equal(True) 144 + takedown_list |> list.contains(suspend_uri) |> should.equal(True) 145 + takedown_list |> list.contains(normal_uri) |> should.equal(False) 146 + } 147 + 148 + // ============================================================================= 149 + // Report Repository Tests 150 + // ============================================================================= 151 + 152 + pub fn report_insert_and_get_test() { 153 + let assert Ok(db) = test_helpers.create_test_db() 154 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 155 + 156 + let reporter_did = "did:plc:reporter123" 157 + let subject_uri = "at://did:plc:user1/app.bsky.feed.post/abc123" 158 + 159 + // Insert a report 160 + let assert Ok(report) = 161 + reports.insert( 162 + db, 163 + reporter_did, 164 + subject_uri, 165 + "spam", 166 + option.Some("This is spam content"), 167 + ) 168 + 169 + report.reporter_did |> should.equal(reporter_did) 170 + report.subject_uri |> should.equal(subject_uri) 171 + report.reason_type |> should.equal("spam") 172 + report.status |> should.equal("pending") 173 + 174 + // Get by ID 175 + let assert Ok(option.Some(found)) = reports.get(db, report.id) 176 + found.id |> should.equal(report.id) 177 + } 178 + 179 + pub fn report_get_all_by_status_test() { 180 + let assert Ok(db) = test_helpers.create_test_db() 181 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 182 + 183 + let reporter = "did:plc:reporter123" 184 + 185 + // Create multiple reports 186 + let assert Ok(_) = 187 + reports.insert(db, reporter, "at://did/post/1", "spam", option.None) 188 + let assert Ok(_) = 189 + reports.insert(db, reporter, "at://did/post/2", "violation", option.None) 190 + let assert Ok(_) = 191 + reports.insert(db, reporter, "at://did/post/3", "rude", option.None) 192 + 193 + // Get all pending reports 194 + let assert Ok(pending) = 195 + reports.get_all(db, option.Some("pending"), 100, option.None) 196 + pending |> list.length() |> should.equal(3) 197 + } 198 + 199 + pub fn report_resolve_test() { 200 + let assert Ok(db) = test_helpers.create_test_db() 201 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 202 + 203 + let admin = "did:plc:admin123" 204 + let reporter = "did:plc:reporter123" 205 + 206 + // Create reports 207 + let assert Ok(report1) = 208 + reports.insert(db, reporter, "at://did/post/1", "spam", option.None) 209 + let assert Ok(report2) = 210 + reports.insert(db, reporter, "at://did/post/2", "violation", option.None) 211 + 212 + // Resolve first report 213 + let assert Ok(resolved) = reports.resolve(db, report1.id, "resolved", admin) 214 + resolved.status |> should.equal("resolved") 215 + resolved.resolved_by |> should.equal(option.Some(admin)) 216 + 217 + // Dismiss second report (status = "dismissed") 218 + let assert Ok(dismissed) = reports.resolve(db, report2.id, "dismissed", admin) 219 + dismissed.status |> should.equal("dismissed") 220 + 221 + // Get pending should return 0 222 + let assert Ok(pending) = 223 + reports.get_all(db, option.Some("pending"), 100, option.None) 224 + pending |> list.length() |> should.equal(0) 225 + 226 + // Get resolved should return 1 227 + let assert Ok(resolved_list) = 228 + reports.get_all(db, option.Some("resolved"), 100, option.None) 229 + resolved_list |> list.length() |> should.equal(1) 230 + } 231 + 232 + // ============================================================================= 233 + // Integration with Records (Takedown Filtering) 234 + // ============================================================================= 235 + 236 + pub fn takedown_label_identified_for_filtering_test() { 237 + let assert Ok(db) = test_helpers.create_test_db() 238 + let assert Ok(_) = test_helpers.create_core_tables(db) 239 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 240 + 241 + let uri1 = "at://did:plc:user1/app.bsky.feed.post/abc123" 242 + let uri2 = "at://did:plc:user2/app.bsky.feed.post/def456" 243 + 244 + // Apply takedown label to first record 245 + let assert Ok(_) = 246 + labels.insert( 247 + db, 248 + "did:plc:admin", 249 + uri1, 250 + option.None, 251 + "!takedown", 252 + option.None, 253 + ) 254 + 255 + // Check that get_takedown_uris returns the takedown URI 256 + let assert Ok(takedown_uris) = labels.get_takedown_uris(db, [uri1, uri2]) 257 + takedown_uris |> list.length() |> should.equal(1) 258 + takedown_uris |> list.contains(uri1) |> should.equal(True) 259 + takedown_uris |> list.contains(uri2) |> should.equal(False) 260 + } 261 + 262 + // ============================================================================= 263 + // Self-Labels Parsing Tests 264 + // ============================================================================= 265 + 266 + pub fn self_labels_parsing_test() { 267 + let assert Ok(db) = test_helpers.create_test_db() 268 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 269 + 270 + let uri = "at://did:plc:user1/app.bsky.feed.post/abc123" 271 + let json_with_self_labels = 272 + "{\"text\": \"hello\", \"labels\": {\"$type\": \"com.atproto.label.defs#selfLabels\", \"values\": [{\"val\": \"porn\"}]}}" 273 + 274 + // Create labels fetcher 275 + let fetcher = fetchers.labels_fetcher(db) 276 + 277 + // Call fetcher with JSON containing self-labels 278 + let assert Ok(results) = fetcher([#(uri, option.Some(json_with_self_labels))]) 279 + 280 + // Should have self-label 281 + let assert Ok(labels_list) = dict.get(results, uri) 282 + labels_list |> list.length() |> should.equal(1) 283 + 284 + // Check the label has correct val and src (author DID) 285 + let assert [label] = labels_list 286 + case label { 287 + value.Object(fields) -> { 288 + let assert Ok(value.String(val)) = list.key_find(fields, "val") 289 + val |> should.equal("porn") 290 + 291 + let assert Ok(value.String(src)) = list.key_find(fields, "src") 292 + src |> should.equal("did:plc:user1") 293 + } 294 + _ -> should.fail() 295 + } 296 + } 297 + 298 + pub fn self_labels_without_labels_field_test() { 299 + let assert Ok(db) = test_helpers.create_test_db() 300 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 301 + 302 + let uri = "at://did:plc:user1/app.bsky.feed.post/abc123" 303 + let json_without_labels = "{\"text\": \"hello world\"}" 304 + 305 + // Create labels fetcher 306 + let fetcher = fetchers.labels_fetcher(db) 307 + 308 + // Call fetcher with JSON without labels 309 + let assert Ok(results) = fetcher([#(uri, option.Some(json_without_labels))]) 310 + 311 + // Should have no labels 312 + case dict.get(results, uri) { 313 + Ok(labels_list) -> labels_list |> list.length() |> should.equal(0) 314 + Error(_) -> Nil 315 + // Also acceptable - no entry means no labels 316 + } 317 + } 318 + 319 + pub fn merged_labels_test() { 320 + let assert Ok(db) = test_helpers.create_test_db() 321 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 322 + 323 + let uri = "at://did:plc:user1/app.bsky.feed.post/abc123" 324 + let json_with_self_labels = 325 + "{\"text\": \"hello\", \"labels\": {\"$type\": \"com.atproto.label.defs#selfLabels\", \"values\": [{\"val\": \"porn\"}]}}" 326 + 327 + // Add moderator label 328 + let assert Ok(_) = 329 + labels.insert(db, "did:plc:admin", uri, option.None, "spam", option.None) 330 + 331 + // Create labels fetcher 332 + let fetcher = fetchers.labels_fetcher(db) 333 + 334 + // Call fetcher with JSON containing self-labels 335 + let assert Ok(results) = fetcher([#(uri, option.Some(json_with_self_labels))]) 336 + 337 + // Should have both labels (1 self + 1 moderator) 338 + let assert Ok(labels_list) = dict.get(results, uri) 339 + labels_list |> list.length() |> should.equal(2) 340 + 341 + // Check we have both "porn" (self) and "spam" (moderator) 342 + let vals = 343 + list.filter_map(labels_list, fn(label) { 344 + case label { 345 + value.Object(fields) -> { 346 + case list.key_find(fields, "val") { 347 + Ok(value.String(v)) -> Ok(v) 348 + _ -> Error(Nil) 349 + } 350 + } 351 + _ -> Error(Nil) 352 + } 353 + }) 354 + vals |> list.contains("porn") |> should.equal(True) 355 + vals |> list.contains("spam") |> should.equal(True) 356 + } 357 + 358 + pub fn multiple_self_labels_test() { 359 + let assert Ok(db) = test_helpers.create_test_db() 360 + let assert Ok(_) = test_helpers.create_moderation_tables(db) 361 + 362 + let uri = "at://did:plc:user1/app.bsky.feed.post/abc123" 363 + let json_with_multiple_labels = 364 + "{\"text\": \"adult content\", \"labels\": {\"$type\": \"com.atproto.label.defs#selfLabels\", \"values\": [{\"val\": \"porn\"}, {\"val\": \"sexual\"}]}}" 365 + 366 + // Create labels fetcher 367 + let fetcher = fetchers.labels_fetcher(db) 368 + 369 + // Call fetcher with JSON containing multiple self-labels 370 + let assert Ok(results) = 371 + fetcher([#(uri, option.Some(json_with_multiple_labels))]) 372 + 373 + // Should have both self-labels 374 + let assert Ok(labels_list) = dict.get(results, uri) 375 + labels_list |> list.length() |> should.equal(2) 376 + }
+93
server/test/test_helpers.gleam
··· 321 321 create_admin_session_table(exec) 322 322 } 323 323 324 + /// Create label_definition table for tests 325 + pub fn create_label_definition_table(exec: Executor) -> Result(Nil, DbError) { 326 + use _ <- result.try( 327 + executor.exec( 328 + exec, 329 + "CREATE TABLE IF NOT EXISTS label_definition ( 330 + val TEXT PRIMARY KEY NOT NULL, 331 + description TEXT NOT NULL, 332 + severity TEXT NOT NULL CHECK (severity IN ('inform', 'alert', 'takedown')), 333 + default_visibility TEXT NOT NULL DEFAULT 'warn', 334 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 335 + )", 336 + [], 337 + ), 338 + ) 339 + 340 + // Seed default label definitions 341 + executor.exec( 342 + exec, 343 + "INSERT INTO label_definition (val, description, severity, default_visibility) VALUES 344 + ('!takedown', 'Content removed by moderators', 'takedown', 'hide'), 345 + ('!suspend', 'Account suspended', 'takedown', 'hide'), 346 + ('!warn', 'Show warning before displaying', 'alert', 'warn'), 347 + ('!hide', 'Hide from feeds', 'alert', 'hide'), 348 + ('porn', 'Pornographic content', 'alert', 'hide'), 349 + ('spam', 'Spam or unwanted content', 'inform', 'warn'), 350 + ('sexual', 'Sexually suggestive content', 'alert', 'warn'), 351 + ('nudity', 'Non-sexual nudity', 'alert', 'warn')", 352 + [], 353 + ) 354 + } 355 + 356 + /// Create actor_label_preference table for tests 357 + pub fn create_label_preference_table(exec: Executor) -> Result(Nil, DbError) { 358 + executor.exec( 359 + exec, 360 + "CREATE TABLE IF NOT EXISTS actor_label_preference ( 361 + did TEXT NOT NULL, 362 + label_val TEXT NOT NULL, 363 + visibility TEXT NOT NULL, 364 + created_at TEXT NOT NULL DEFAULT (datetime('now')), 365 + PRIMARY KEY (did, label_val) 366 + )", 367 + [], 368 + ) 369 + } 370 + 371 + /// Create label table for tests 372 + pub fn create_label_table(exec: Executor) -> Result(Nil, DbError) { 373 + executor.exec( 374 + exec, 375 + "CREATE TABLE IF NOT EXISTS label ( 376 + id INTEGER PRIMARY KEY AUTOINCREMENT, 377 + src TEXT NOT NULL, 378 + uri TEXT NOT NULL, 379 + cid TEXT, 380 + val TEXT NOT NULL, 381 + neg INTEGER NOT NULL DEFAULT 0, 382 + cts TEXT NOT NULL DEFAULT (datetime('now')), 383 + exp TEXT, 384 + FOREIGN KEY (val) REFERENCES label_definition(val) 385 + )", 386 + [], 387 + ) 388 + } 389 + 390 + /// Create report table for tests 391 + pub fn create_report_table(exec: Executor) -> Result(Nil, DbError) { 392 + executor.exec( 393 + exec, 394 + "CREATE TABLE IF NOT EXISTS report ( 395 + id INTEGER PRIMARY KEY AUTOINCREMENT, 396 + reporter_did TEXT NOT NULL, 397 + subject_uri TEXT NOT NULL, 398 + reason_type TEXT NOT NULL CHECK (reason_type IN ('spam', 'violation', 'misleading', 'sexual', 'rude', 'other')), 399 + reason TEXT, 400 + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'resolved', 'dismissed')), 401 + resolved_by TEXT, 402 + resolved_at TEXT, 403 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 404 + )", 405 + [], 406 + ) 407 + } 408 + 409 + /// Create labels and reports tables for tests 410 + pub fn create_moderation_tables(exec: Executor) -> Result(Nil, DbError) { 411 + use _ <- result.try(create_label_definition_table(exec)) 412 + use _ <- result.try(create_label_table(exec)) 413 + use _ <- result.try(create_report_table(exec)) 414 + create_label_preference_table(exec) 415 + } 416 + 324 417 /// Insert a test token that maps to a DID for testing viewer authentication 325 418 pub fn insert_test_token( 326 419 exec: Executor,